Navigation utilisateur : avatar, paramétrage, maintenance, mot de passe

Réorganisation complète de la navigation suite au retour d'usage, plus
changement de mot de passe et affichage/masquage des mots de passe saisis.

Backend :
- Nouvel endpoint POST /api/auth/change-password (ChangePasswordDto,
  MotDePasseActuelInvalideException, validateur, AuthService.ChangePasswordAsync).
  Exige l'ancien mot de passe, distinct de la réinitialisation par e-mail.
- Endpoint placé dans SessionController (protégé par l'autorisation globale) et
  NON dans AuthController : AuthController porte [AllowAnonymous] au niveau
  classe, ce qui rend inopérant un [Authorize] posé sur une seule action (bug de
  sécurité détecté par test manuel — l'endpoint était accessible sans jeton —
  puis corrigé par ce déplacement).

Frontend :
- Page « Compte utilisateur » (ProfilComponent renommé) : section Maintenance
  extraite vers MaintenanceComponent (route /maintenance) ; nouvelle section de
  changement de mot de passe (ancien + nouveau + confirmation).
- ParametrageComponent (route /parametrage) : menu latéral Compte / Véhicules /
  Barème + contenu en routes enfants, réutilisant les composants existants.
  Compte et Véhicules en authGuard seul ; Barème garde profilConfigureGuard.
- Barre de navigation : Véhicules et Barème retirés (rejoignent Paramétrage) ;
  logo passé en format horizontal (icône + texte).
- Avatar utilisateur (initiales) à droite de la barre : sous-menu au survol
  (identité, Paramétrage, Maintenance, Se déconnecter). Menu « Specs » converti
  en dropdown survolable (Exigences / Conception / Tâches).
- Dropdowns : panneau collé au bouton (plus d'espace mort) pour que le survol
  reste continu jusqu'au panneau — un espace créait une fermeture prématurée.
- Barre masquée sur les pages publiques (login/register/forgot/reset-password)
  même avec un jeton résiduel expiré/invalide (détection via Router events, pas
  seulement la présence du jeton).
- PasswordInputComponent (shared) : bouton œil afficher/masquer, ControlValueAccessor,
  style aligné sur les champs texte classiques. Appliqué aux 8 champs mot de
  passe de l'application (login, register, reset-password, compte utilisateur).

Specs : requirements.md (exigences 20, 21 + glossaire) et design.md (endpoints,
DTO, composants) mis à jour. 54 tests backend au vert.
This commit is contained in:
2026-09-22 09:54:25 +02:00
parent 058481c192
commit ee47d04b90
36 changed files with 1232 additions and 118 deletions
+42 -6
View File
@@ -34,6 +34,8 @@
| 22 | Trajet aller/retour | Case à cocher A/R doublant la distance comptée (Distance_Effective) sans ligne supplémentaire ; badge A/R en liste et mention « (A/R) » sur la carte | | 22 | Trajet aller/retour | Case à cocher A/R doublant la distance comptée (Distance_Effective) sans ligne supplémentaire ; badge A/R en liste et mention « (A/R) » sur la carte |
| 23 | Barème multi-tranches & recopie | Tranches paramétrables par puissance fiscale (dernière tranche illimitée), coefficient sur 3 décimales, bouton « Recopier le dernier barème connu » | | 23 | Barème multi-tranches & recopie | Tranches paramétrables par puissance fiscale (dernière tranche illimitée), coefficient sur 3 décimales, bouton « Recopier le dernier barème connu » |
| 24 | Consultation des specs | Lien « Specs » vers une page dédiée (menu latéral Exigences / Conception / Tâches) affichant les documents Markdown rendus (dont les diagrammes Mermaid), en lecture seule et derrière l'authentification | | 24 | Consultation des specs | Lien « Specs » vers une page dédiée (menu latéral Exigences / Conception / Tâches) affichant les documents Markdown rendus (dont les diagrammes Mermaid), en lecture seule et derrière l'authentification |
| 25 | Changement de mot de passe | Page Compte utilisateur : formulaire dédié (mot de passe actuel + nouveau + confirmation) ; endpoint protégé distinct de la réinitialisation par e-mail |
| 26 | Navigation utilisateur | Avatar (initiales) à droite de la barre, sous-menu au survol (Prénom Nom, Paramétrage, Maintenance, Se déconnecter) ; Page_Parametrage (Compte / Véhicules / Barème) et Page_Maintenance dédiées ; barre masquée sur les pages publiques ; contrôle afficher/masquer sur tous les champs mot de passe |
### Contraintes techniques ### Contraintes techniques
@@ -271,12 +273,24 @@ src/
- Infobulles au clic (popup) et au survol (tooltip) : commentaire en tête (si présent), adresse départ, adresse arrivée, distance suivie de « (A/R) » pour un aller/retour ; largeur bornée avec retour à la ligne pour ne pas déborder de la carte - Infobulles au clic (popup) et au survol (tooltip) : commentaire en tête (si présent), adresse départ, adresse arrivée, distance suivie de « (A/R) » pour un aller/retour ; largeur bornée avec retour à la ligne pour ne pas déborder de la carte
- Fond de carte : OpenStreetMap (tile layer `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`) - Fond de carte : OpenStreetMap (tile layer `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`)
**`ProfilComponent`** **`ProfilComponent`** (page « Compte utilisateur » — Exigences 10, 20)
- Champs : nom (1–100 car.), prénom (1–100 car.), matricule (max 8 car., limite de saisie stricte), adresse de domicile (1–255 car.) - Titre affiché : « Compte utilisateur » (aligné avec les intitulés « Véhicules » et « Barème » du menu de la Page_Parametrage)
- Bouton "Enregistrer" - Formulaire d'identité : nom (1–100 car.), prénom (1–100 car.), matricule (max 8 car., limite de saisie stricte), adresse de domicile (1–255 car.)
- Message d'avertissement si le géocodage de l'adresse a échoué - Bouton "Enregistrer" ; message d'avertissement si le géocodage de l'adresse a échoué
- **Formulaire de changement de mot de passe** (distinct, Exigence 20) : mot de passe actuel + nouveau + confirmation (validateur de groupe d'égalité, même pattern que `RegisterComponent`) ; `POST /api/auth/change-password` via `AuthService.changePassword` ; une erreur 400 affiche « Le mot de passe actuel est incorrect » sur le formulaire
- La section « Maintenance » (recalcul des distances) a été extraite vers `MaintenanceComponent` (route `/maintenance`, accessible depuis l'Avatar_Utilisateur)
- Route : `/parametrage/compte` (au sein de la Page_Parametrage)
**`VehiculesComponent`** **`MaintenanceComponent`** (route `/maintenance` — Exigence 21)
- Reprend l'action de recalcul des distances (`POST /api/trajets/recalculer-distances`) extraite de l'ancienne page Profil
- Accessible depuis le sous-menu de l'Avatar_Utilisateur, `authGuard` seul (pas de dépendance au profil configuré)
**`ParametrageComponent`** (route `/parametrage` — Exigence 21)
- Layout menu latéral + contenu, sur le même modèle que `SpecsComponent` : colonne gauche (« Compte », « Véhicules », « Barème »), colonne droite = `<router-outlet>` avec les routes enfants `parametrage/compte`, `parametrage/vehicules`, `parametrage/bareme`
- Réutilise `ProfilComponent`, `VehiculesComponent`, `BaremeComponent` sans duplication ; seul le point d'entrée change
- Gardes : `compte` et `vehicules` sur `authGuard` seul ; `bareme` ajoute `profilConfigureGuard` (Exigence 21.6)
**`VehiculesComponent`** (route `/parametrage/vehicules`)
- Liste de l'historique des véhicules (plaque, puissance fiscale, date d'effet), triée par date d'effet décroissante - Liste de l'historique des véhicules (plaque, puissance fiscale, date d'effet), triée par date d'effet décroissante
- Formulaire d'ajout d'un véhicule : plaque, puissance fiscale, date d'effet (<= date du jour, sélecteur bloquant les dates futures) - Formulaire d'ajout d'un véhicule : plaque, puissance fiscale, date d'effet (<= date du jour, sélecteur bloquant les dates futures)
- À l'enregistrement, si la réponse `VehiculeCreeDto.ReaffectationProposee` est true, ouvre `ReaffectationDialogComponent` - À l'enregistrement, si la réponse `VehiculeCreeDto.ReaffectationProposee` est true, ouvre `ReaffectationDialogComponent`
@@ -358,7 +372,7 @@ src/
> **Décision — stockage du JWT dans `localStorage`** : retenu pour sa simplicité, malgré le risque XSS, acceptable pour une application personnelle mono-utilisateur. Le cookie HttpOnly a été rejeté pour éviter la complexité de protection CSRF. > **Décision — stockage du JWT dans `localStorage`** : retenu pour sa simplicité, malgré le risque XSS, acceptable pour une application personnelle mono-utilisateur. Le cookie HttpOnly a été rejeté pour éviter la complexité de protection CSRF.
**`SpecsComponent`** (documents de spécification — Exigence 19) **`SpecsComponent`** (documents de spécification — Exigence 19)
- La barre de navigation principale expose un simple lien « Specs » vers `/specs` (redirigé vers le premier document) ; le choix du document se fait dans le **menu latéral** de la page, pas dans la barre. - La barre de navigation principale expose un menu déroulant « Specs » (survol ou clic, même mécanisme que l'Avatar_Utilisateur) listant directement les 3 documents ; le choix détaillé (disponibilité, etc.) reste géré par le **menu latéral** de la page elle-même.
- Route paramétrée `/specs/:cle` ; **lazy-loadée** (`loadComponent`) pour sortir la librairie Markdown du bundle initial - Route paramétrée `/specs/:cle` ; **lazy-loadée** (`loadComponent`) pour sortir la librairie Markdown du bundle initial
- Colonne gauche : menu listant les documents (`GET /api/specs`), le document indisponible étant signalé ; colonne droite : contenu rendu - Colonne gauche : menu listant les documents (`GET /api/specs`), le document indisponible étant signalé ; colonne droite : contenu rendu
- Rendu : le Markdown (`GET /api/specs/{cle}`) est converti en HTML par **`marked`** (GFM : titres, listes, tableaux, code), puis **assaini par `DOMPurify`** avant injection (`[innerHTML]`) — Exigence 19.7 - Rendu : le Markdown (`GET /api/specs/{cle}`) est converti en HTML par **`marked`** (GFM : titres, listes, tableaux, code), puis **assaini par `DOMPurify`** avant injection (`[innerHTML]`) — Exigence 19.7
@@ -372,6 +386,18 @@ src/
- **Gestion d'erreur** : en cas d'échec de récupération de l'indicateur (backend injoignable / erreur réseau), le guard affiche un message d'erreur de connexion avec une action "Réessayer" plutôt que de rediriger arbitrairement, laissant l'utilisateur relancer la vérification - **Gestion d'erreur** : en cas d'échec de récupération de l'indicateur (backend injoignable / erreur réseau), le guard affiche un message d'erreur de connexion avec une action "Réessayer" plutôt que de rediriger arbitrairement, laissant l'utilisateur relancer la vérification
- Déclaré dans `app-routing.module.ts` sur les routes protégées (consultation, saisie, résumés, etc.) ; la route `/onboarding` reste accessible sans garde - Déclaré dans `app-routing.module.ts` sur les routes protégées (consultation, saisie, résumés, etc.) ; la route `/onboarding` reste accessible sans garde
**`AppComponent`** (coquille applicative — barre de navigation, Exigence 21)
- Barre de navigation : logo (format horizontal, icône + texte), liens métier (Trajets, Carnet d'adresses, Montants réglés, Résumé mensuel, Résumé annuel), menu déroulant « Specs », puis l'Avatar_Utilisateur poussé à l'extrémité droite (`margin-left: auto`)
- **Menus déroulants (Specs, Avatar)** : s'ouvrent au survol (`mouseenter`) ET restent utilisables au clic (tactile, clavier) ; se ferment au `mouseleave` du bloc conteneur ou au clic extérieur (`@HostListener('document:click')`)
- **Piège évité** : le panneau déroulant ne doit PAS être séparé du bouton par un espace (`top: calc(100% + Xpx)`), sous peine de déclencher un `mouseleave` prématuré lorsque le curseur traverse cet espace mort en descendant vers le panneau. Le panneau touche directement le bouton (`top: 100%`) et l'espacement visuel est reporté en `padding-top` À L'INTÉRIEUR du panneau (zone `__panneau-contenu` séparée pour le fond/l'ombre), de sorte que la zone de survol reste continue.
- **Affichage conditionnel** : la barre de navigation (avatar inclus) est masquée non seulement en l'absence de jeton, mais aussi sur les pages PUBLIQUES (`/login`, `/register`, `/forgot-password`, `/reset-password`), détectées via les événements du `Router` (`NavigationEnd`). Ce deuxième critère est nécessaire car un jeton résiduel expiré ou invalide peut rester en `localStorage` sans qu'aucun appel HTTP protégé ne le purge sur ces pages (aucune requête vers un endpoint protégé n'y est faite) ; se fier uniquement à la présence du jeton ferait réapparaître la barre à tort.
- **Avatar** : initiales calculées à partir du Profil (`ProfilService.getProfil()`, chargé via un `effect()` déclenché dès que l'utilisateur authentifié quitte une page publique) ; sous-menu = identité (Prénom Nom), « Paramétrage » (`/parametrage`), « Maintenance » (`/maintenance`), « Se déconnecter » (`AuthService.logout()`)
**`PasswordInputComponent`** (`shared/components/password-input/` — Exigence 21.8)
- Champ de mot de passe réutilisable avec bouton « œil » (SVG inline, pas d'icône externe) pour basculer l'affichage entre masqué et visible
- Implémente `ControlValueAccessor` : s'utilise comme un `<input>` classique avec `formControlName` (remplace directement `<input type="password">` dans les 4 pages d'authentification et la page Compte utilisateur, 8 occurrences au total)
- **Style aligné sur les champs texte classiques** (`padding: 0.5rem`, `border: 1px solid #bbb`, `border-radius: 6px`, `font-size: 0.95rem`, variante rouge si `aria-invalid`) : porté par le composant lui-même (pas par la page hôte, qui ne peut pas styler l'intérieur d'un composant enfant à travers l'encapsulation de vue Angular) afin que le rendu soit identique à celui d'un champ texte normal quelle que soit la page
#### Services Angular #### Services Angular
**`TrajetService`** **`TrajetService`**
@@ -1200,6 +1226,13 @@ public record ResetPasswordDto(
string MotDePasse // nouveau mot de passe, >= 8 caractères string MotDePasse // nouveau mot de passe, >= 8 caractères
); );
// Changement de mot de passe par un utilisateur authentifié (Exigence 20). À la
// différence de ResetPasswordDto (jeton reçu par email), exige l'ancien mot de passe.
public record ChangePasswordDto(
string MotDePasseActuel,
string NouveauMotDePasse // >= 8 caractères
);
// --- Documents de spécification (Exigence 19) --- // --- Documents de spécification (Exigence 19) ---
// Métadonnées d'un document (élément du sous-menu) // Métadonnées d'un document (élément du sous-menu)
@@ -1246,6 +1279,9 @@ public record SpecContenuDto(
| `GET` | `/api/auth/moi` | Valider la session courante (jeton valide + compte existant) — endpoint **protégé** | — | `200 OK` ; `401` sinon | | `GET` | `/api/auth/moi` | Valider la session courante (jeton valide + compte existant) — endpoint **protégé** | — | `200 OK` ; `401` sinon |
| `POST` | `/api/auth/forgot-password` | Demander un lien de réinitialisation (envoi email, sinon lien journalisé) | `ForgotPasswordDto` | `200 OK` | | `POST` | `/api/auth/forgot-password` | Demander un lien de réinitialisation (envoi email, sinon lien journalisé) | `ForgotPasswordDto` | `200 OK` |
| `POST` | `/api/auth/reset-password` | Réinitialiser le mot de passe via le jeton reçu | `ResetPasswordDto` | `200 OK` ; `400` si jeton invalide/expiré/déjà utilisé | | `POST` | `/api/auth/reset-password` | Réinitialiser le mot de passe via le jeton reçu | `ResetPasswordDto` | `200 OK` ; `400` si jeton invalide/expiré/déjà utilisé |
| `POST` | `/api/auth/change-password` | Changer le mot de passe d'un utilisateur **authentifié** (exige l'ancien mot de passe) — endpoint **protégé** | `ChangePasswordDto` | `200 OK` ; `400` si mot de passe actuel incorrect ; `401` sans jeton |
> **Placement de `change-password`** : cet endpoint est déclaré dans `SessionController` (route `api/auth`, SANS `[AllowAnonymous]` au niveau classe) et non dans `AuthController` (qui porte `[AllowAnonymous]` au niveau classe). En ASP.NET Core, `[AllowAnonymous]` posé sur une classe s'applique à TOUTES ses actions et ne peut pas être contredit par un `[Authorize]` posé sur une seule action : un premier essai avec `[Authorize]` sur l'action dans `AuthController` a laissé l'endpoint accessible sans jeton (vérifié par test manuel), d'où le déplacement vers `SessionController`, protégé par l'autorisation globale de l'application.
#### Résumés #### Résumés
@@ -63,6 +63,9 @@ L'application est composée d'un frontend Angular/TypeScript et d'un backend C#.
- **Aller_Retour** : Un indicateur booléen d'un Trajet signifiant que le déplacement est effectué dans les deux sens. Lorsqu'il est vrai, la Distance comptée du Trajet (Distance_Effective) est le double de la distance simple mesurée entre le départ et l'arrivée. - **Aller_Retour** : Un indicateur booléen d'un Trajet signifiant que le déplacement est effectué dans les deux sens. Lorsqu'il est vrai, la Distance comptée du Trajet (Distance_Effective) est le double de la distance simple mesurée entre le départ et l'arrivée.
- **Distance_Effective** : La distance comptée d'un Trajet dans tous les agrégats (liste, totaux, carte, PDF, barème) : égale à la distance simple, ou à son double lorsque le Trajet est un Aller_Retour. - **Distance_Effective** : La distance comptée d'un Trajet dans tous les agrégats (liste, totaux, carte, PDF, barème) : égale à la distance simple, ou à son double lorsque le Trajet est un Aller_Retour.
- **Document_Specification** : Un document de conception du projet consultable en lecture seule dans l'Application : les Exigences (requirements), la Conception technique (design) et le Plan de tâches (tasks), rendus depuis leur source Markdown. - **Document_Specification** : Un document de conception du projet consultable en lecture seule dans l'Application : les Exigences (requirements), la Conception technique (design) et le Plan de tâches (tasks), rendus depuis leur source Markdown.
- **Avatar_Utilisateur** : Le contrôle affiché à l'extrémité droite de la barre de navigation, représentant l'utilisateur par ses initiales (1ère lettre du Prénom + 1ère lettre du Nom). Un survol ou un clic ouvre un sous-menu proposant l'identité affichée, « Paramétrage », « Maintenance » et « Se déconnecter ».
- **Page_Parametrage** : La page accessible depuis l'Avatar_Utilisateur, présentant un menu latéral (Compte, Véhicules, Barème) et affichant à sa droite la donnée paramétrable sélectionnée.
- **Page_Maintenance** : La page accessible depuis l'Avatar_Utilisateur regroupant les actions techniques de maintenance de l'Application (ex. recalcul des distances routières).
--- ---
@@ -454,3 +457,34 @@ L'application est composée d'un frontend Angular/TypeScript et d'un backend C#.
7. THE Frontend SHALL assainir le contenu HTML issu du rendu Markdown avant affichage, afin d'éviter l'injection de contenu actif. 7. THE Frontend SHALL assainir le contenu HTML issu du rendu Markdown avant affichage, afin d'éviter l'injection de contenu actif.
8. IF un diagramme Mermaid ne peut pas être rendu (syntaxe non reconnue), THEN THE Frontend SHALL conserver l'affichage du bloc de code brut correspondant sans empêcher le rendu des autres diagrammes ni du reste du document. 8. IF un diagramme Mermaid ne peut pas être rendu (syntaxe non reconnue), THEN THE Frontend SHALL conserver l'affichage du bloc de code brut correspondant sans empêcher le rendu des autres diagrammes ni du reste du document.
---
### Requirement 20: Changement de mot de passe par un utilisateur authentifié
**User Story:** En tant qu'utilisateur authentifié, je veux changer mon mot de passe depuis mon compte, afin de le renouveler sans passer par le parcours de mot de passe oublié.
#### Acceptance Criteria
1. THE Frontend SHALL afficher, sur la page « Compte utilisateur », un formulaire de changement de Mot_De_Passe distinct du formulaire d'identité, comportant le mot de passe actuel, le nouveau Mot_De_Passe et sa confirmation.
2. THE Frontend SHALL exiger que le nouveau Mot_De_Passe comporte au moins 8 caractères et corresponde exactement à sa confirmation avant d'autoriser la soumission.
3. WHEN l'utilisateur soumet le changement de Mot_De_Passe, THE Backend SHALL vérifier que le mot de passe actuel fourni correspond au Compte AVANT de le remplacer ; cette vérification exige un utilisateur authentifié (jeton JWT valide).
4. IF le mot de passe actuel fourni ne correspond pas au Compte, THEN THE Backend SHALL rejeter le changement avec une erreur HTTP 400 explicite, sans modifier le Mot_De_Passe existant.
5. WHEN le mot de passe actuel est correct et le nouveau Mot_De_Passe valide, THE Backend SHALL remplacer le hachage du Mot_De_Passe du Compte et THE Frontend SHALL afficher une confirmation de succès.
6. THE Backend SHALL exposer l'endpoint de changement de mot de passe sur un contrôleur soumis à l'autorisation globale (utilisateur authentifié), et non sur un contrôleur autorisant les accès anonymes, afin qu'un `[Authorize]` posé sur une seule action ne puisse pas être contourné par une déclaration `[AllowAnonymous]` au niveau de la classe.
---
### Requirement 21: Navigation utilisateur (avatar, paramétrage, maintenance)
**User Story:** En tant qu'utilisateur, je veux accéder à mon compte, aux données paramétrables et aux actions de maintenance depuis un menu utilisateur unique, afin de garder la barre de navigation principale légère et centrée sur les fonctionnalités de saisie et de consultation.
#### Acceptance Criteria
1. THE Frontend SHALL afficher un Avatar_Utilisateur à l'extrémité droite de la barre de navigation, représentant l'utilisateur par ses initiales (1ère lettre du Prénom suivie de la 1ère lettre du Nom, en majuscules) ; à défaut d'identité connue, un caractère générique est affiché.
2. WHEN l'utilisateur survole ou clique sur l'Avatar_Utilisateur, THE Frontend SHALL afficher un sous-menu présentant le Prénom et le Nom de l'utilisateur, puis les options « Paramétrage », « Maintenance » et « Se déconnecter ».
3. THE Frontend SHALL fermer le sous-menu de l'Avatar_Utilisateur lorsque le curseur quitte la zone du bouton et du panneau déroulant réunis, sans espace mort entre les deux qui provoquerait une fermeture prématurée lors du déplacement du curseur vers le panneau.
4. WHEN l'utilisateur sélectionne « Paramétrage », THE Frontend SHALL naviguer vers la Page_Parametrage, affichant par défaut la donnée « Compte » ; WHEN l'utilisateur sélectionne « Maintenance », THE Frontend SHALL naviguer vers la Page_Maintenance ; WHEN l'utilisateur sélectionne « Se déconnecter », THE Frontend SHALL déconnecter l'utilisateur et naviguer vers la page de connexion.
5. THE Page_Parametrage SHALL présenter un menu latéral avec les entrées « Compte » (identité, matricule, adresse de domicile), « Véhicules » (historique des véhicules) et « Barème » (barème kilométrique), chacune affichant à droite le contenu correspondant sans rechargement complet de la page.
6. THE Backend SHALL continuer à exiger la configuration du Profil (Exigence 16) pour l'accès à la donnée « Barème » de la Page_Parametrage, mais PAS pour l'accès aux données « Compte » et « Véhicules », afin de ne pas bloquer un utilisateur dont le Profil n'est pas encore configuré.
7. THE Frontend SHALL masquer entièrement la barre de navigation (y compris l'Avatar_Utilisateur) sur les pages non authentifiées (connexion, inscription, mot de passe oublié, réinitialisation), MÊME SI un jeton résiduel expiré ou invalide est encore présent localement, la présence locale d'un jeton ne garantissant pas la validité réelle de la Session sur ces pages.
8. THE Frontend SHALL proposer, sur chaque champ de saisie d'un Mot_De_Passe (connexion, inscription, réinitialisation, changement de mot de passe), un contrôle permettant de basculer l'affichage entre masqué et visible, afin que l'utilisateur puisse vérifier la saisie ; ce contrôle SHALL présenter un rendu visuel identique à celui des autres champs de saisie du formulaire.
@@ -1,18 +1,30 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Services;
using SchotzIadKmTracker.Api.Services.Exceptions;
namespace SchotzIadKmTracker.Api.Controllers; namespace SchotzIadKmTracker.Api.Controllers;
/// <summary> /// <summary>
/// Endpoint de validation de session. Ce controller est soumis à l'autorisation globale /// Endpoints réservés à un utilisateur AUTHENTIFIÉ. Ce controller ne porte PAS
/// (RequireAuthenticatedUser) : il n'est atteignable qu'avec un jeton valide ET un compte /// <c>[AllowAnonymous]</c> : il est donc soumis à l'autorisation globale
/// existant (le middleware JWT rejette un jeton dont le compte est introuvable via /// (RequireAuthenticatedUser) — atteignable uniquement avec un jeton valide ET un
/// OnTokenValidated). Le frontend l'appelle pour confirmer qu'une session est réellement /// compte existant (le middleware JWT rejette un jeton dont le compte est introuvable
/// valide au chargement des routes protégées. /// via OnTokenValidated). C'est pourquoi le changement de mot de passe est placé ICI
/// et non dans AuthController (qui porte [AllowAnonymous] au niveau classe : un
/// [Authorize] sur une action de ce dernier n'aurait pas suffi à exiger un jeton).
/// </summary> /// </summary>
[ApiController] [ApiController]
[Route("api/auth")] [Route("api/auth")]
public class SessionController : ControllerBase public class SessionController : ControllerBase
{ {
private readonly IAuthService _authService;
public SessionController(IAuthService authService)
{
_authService = authService;
}
/// <summary> /// <summary>
/// Retourne 200 si la session est valide (jeton valide + compte existant), 401 sinon. /// Retourne 200 si la session est valide (jeton valide + compte existant), 401 sinon.
/// </summary> /// </summary>
@@ -23,4 +35,29 @@ public class SessionController : ControllerBase
{ {
return Ok(new { authentifie = true }); return Ok(new { authentifie = true });
} }
/// <summary>
/// Change le mot de passe d'un utilisateur authentifié (page « Compte utilisateur »).
/// Contrairement à la réinitialisation par jeton email, exige l'ancien mot de passe.
/// Un mot de passe actuel incorrect est rejeté en 400 ; les erreurs de validation du
/// nouveau mot de passe (longueur) sont renvoyées automatiquement en 400 par FluentValidation.
/// </summary>
[HttpPost("change-password")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ChangePassword(
[FromBody] ChangePasswordDto dto,
CancellationToken cancellationToken)
{
try
{
await _authService.ChangePasswordAsync(dto, cancellationToken);
return Ok();
}
catch (MotDePasseActuelInvalideException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
}
} }
@@ -17,5 +17,9 @@ public record ForgotPasswordDto(string Email);
// Réinitialisation via jeton de reset (Requirement 7). // Réinitialisation via jeton de reset (Requirement 7).
public record ResetPasswordDto(string Token, string NouveauMotDePasse); public record ResetPasswordDto(string Token, string NouveauMotDePasse);
// Changement de mot de passe par un utilisateur authentifié (page Compte utilisateur).
// Contrairement à ResetPassword (jeton reçu par email), exige l'ANCIEN mot de passe.
public record ChangePasswordDto(string MotDePasseActuel, string NouveauMotDePasse);
// Indique si un compte existe déjà, pour orienter le frontend (Requirement 1.2, 9.1). // Indique si un compte existe déjà, pour orienter le frontend (Requirement 1.2, 9.1).
public record CompteExisteDto(bool Existe); public record CompteExisteDto(bool Existe);
@@ -186,6 +186,30 @@ public class AuthService : IAuthService
} }
} }
/// <inheritdoc />
public async Task ChangePasswordAsync(ChangePasswordDto dto, CancellationToken ct)
{
var compte = await _context.Comptes.FirstOrDefaultAsync(ct);
if (compte is null)
{
// Cas théorique (endpoint protégé, donc un compte existe forcément) : traité
// comme un mot de passe actuel invalide, sans distinction pour l'appelant.
throw new MotDePasseActuelInvalideException("Le mot de passe actuel est incorrect.");
}
var resultat = _hasher.VerifyHashedPassword(compte, compte.MotDePasseHash, dto.MotDePasseActuel);
if (resultat == PasswordVerificationResult.Failed)
{
throw new MotDePasseActuelInvalideException("Le mot de passe actuel est incorrect.");
}
// La longueur >= 8 du nouveau mot de passe est garantie par FluentValidation en amont.
compte.MotDePasseHash = _hasher.HashPassword(compte, dto.NouveauMotDePasse);
compte.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync(ct);
}
/// <summary> /// <summary>
/// Génère un jeton aléatoire cryptographiquement sûr (32 octets) encodé en base64url. /// Génère un jeton aléatoire cryptographiquement sûr (32 octets) encodé en base64url.
/// </summary> /// </summary>
@@ -0,0 +1,19 @@
namespace SchotzIadKmTracker.Api.Services.Exceptions;
/// <summary>
/// Levée lors d'un changement de mot de passe (utilisateur authentifié) lorsque le
/// mot de passe actuel fourni ne correspond pas à celui du Compte.
/// Convertie en réponse HTTP 400 (Bad Request) par le controller.
/// </summary>
public class MotDePasseActuelInvalideException : Exception
{
public MotDePasseActuelInvalideException(string message)
: base(message)
{
}
public MotDePasseActuelInvalideException(string message, Exception innerException)
: base(message, innerException)
{
}
}
@@ -49,4 +49,14 @@ public interface IAuthService
/// <param name="dto">Jeton de réinitialisation et nouveau mot de passe.</param> /// <param name="dto">Jeton de réinitialisation et nouveau mot de passe.</param>
/// <param name="ct">Jeton d'annulation de l'opération.</param> /// <param name="ct">Jeton d'annulation de l'opération.</param>
Task ResetPasswordAsync(ResetPasswordDto dto, CancellationToken ct); Task ResetPasswordAsync(ResetPasswordDto dto, CancellationToken ct);
/// <summary>
/// Change le mot de passe d'un utilisateur déjà authentifié (page Compte utilisateur).
/// Contrairement à <see cref="ResetPasswordAsync"/> (jeton reçu par email), exige la
/// saisie de l'ancien mot de passe. Lève
/// <see cref="Exceptions.MotDePasseActuelInvalideException"/> si celui-ci est incorrect.
/// </summary>
/// <param name="dto">Mot de passe actuel et nouveau mot de passe.</param>
/// <param name="ct">Jeton d'annulation de l'opération.</param>
Task ChangePasswordAsync(ChangePasswordDto dto, CancellationToken ct);
} }
@@ -56,3 +56,17 @@ public class ResetPasswordDtoValidator : AbstractValidator<ResetPasswordDto>
.MinimumLength(8).WithMessage("Le mot de passe doit comporter au moins 8 caractères."); .MinimumLength(8).WithMessage("Le mot de passe doit comporter au moins 8 caractères.");
} }
} }
// Validation du changement de mot de passe par un utilisateur authentifié (page Compte utilisateur).
public class ChangePasswordDtoValidator : AbstractValidator<ChangePasswordDto>
{
public ChangePasswordDtoValidator()
{
RuleFor(x => x.MotDePasseActuel)
.NotEmpty().WithMessage("Le mot de passe actuel est obligatoire.");
RuleFor(x => x.NouveauMotDePasse)
.NotEmpty().WithMessage("Le nouveau mot de passe est obligatoire.")
.MinimumLength(8).WithMessage("Le nouveau mot de passe doit comporter au moins 8 caractères.");
}
}
+101 -14
View File
@@ -1,7 +1,10 @@
<!-- Coquille applicative : barre de navigation globale + contenu routé. --> <!-- Coquille applicative : barre de navigation globale + contenu routé.
La barre est masquée tant que l'utilisateur n'est pas authentifié (pages de
connexion, inscription, mot de passe oublié / réinitialisation). -->
@if (estAuthentifie()) {
<nav class="nav-bar" aria-label="Navigation principale"> <nav class="nav-bar" aria-label="Navigation principale">
<a class="nav-bar__logo" routerLink="/consultation" aria-label="SchotzIAD — Accueil"> <a class="nav-bar__logo" routerLink="/consultation" aria-label="SchotzIAD — Accueil">
<img src="assets/images/logo-icon.png" alt="" width="32" height="32" /> <img src="assets/images/logo-horizontal.png" alt="SchotzIAD" width="106" height="36" />
</a> </a>
<a class="nav-bar__lien" routerLink="/consultation" routerLinkActive="nav-bar__lien--actif"> <a class="nav-bar__lien" routerLink="/consultation" routerLinkActive="nav-bar__lien--actif">
@@ -10,15 +13,6 @@
<a class="nav-bar__lien" routerLink="/carnet-adresses" routerLinkActive="nav-bar__lien--actif"> <a class="nav-bar__lien" routerLink="/carnet-adresses" routerLinkActive="nav-bar__lien--actif">
Carnet d'adresses Carnet d'adresses
</a> </a>
<a class="nav-bar__lien" routerLink="/profil" routerLinkActive="nav-bar__lien--actif">
Profil
</a>
<a class="nav-bar__lien" routerLink="/vehicules" routerLinkActive="nav-bar__lien--actif">
Véhicules
</a>
<a class="nav-bar__lien" routerLink="/bareme" routerLinkActive="nav-bar__lien--actif">
Barème
</a>
<a class="nav-bar__lien" routerLink="/montants-regles" routerLinkActive="nav-bar__lien--actif"> <a class="nav-bar__lien" routerLink="/montants-regles" routerLinkActive="nav-bar__lien--actif">
Montants réglés Montants réglés
</a> </a>
@@ -29,10 +23,103 @@
Résumé annuel Résumé annuel
</a> </a>
<a class="nav-bar__lien" routerLink="/specs" routerLinkActive="nav-bar__lien--actif"> <!-- Menu « Specs » : sous-menu (Exigences / Conception technique / Plan de tâches)
Specs qui s'ouvre au survol (comme l'avatar), utilisable aussi au clic. -->
</a> <div class="specs-menu" (mouseenter)="ouvrirMenuSpecs()" (mouseleave)="fermerMenuSpecs()">
<button
type="button"
class="nav-bar__lien specs-menu__bouton"
[class.specs-menu__bouton--actif]="menuSpecsOuvert()"
[attr.aria-expanded]="menuSpecsOuvert()"
aria-haspopup="true"
(click)="basculerMenuSpecs()"
>
Specs
</button>
@if (menuSpecsOuvert()) {
<div class="specs-menu__panneau" role="menu">
<div class="specs-menu__panneau-contenu">
<a
class="specs-menu__lien"
role="menuitem"
routerLink="/specs/requirements"
routerLinkActive="specs-menu__lien--actif"
(click)="fermerMenuSpecs()"
>
Exigences
</a>
<a
class="specs-menu__lien"
role="menuitem"
routerLink="/specs/design"
routerLinkActive="specs-menu__lien--actif"
(click)="fermerMenuSpecs()"
>
Conception technique
</a>
<a
class="specs-menu__lien"
role="menuitem"
routerLink="/specs/tasks"
routerLinkActive="specs-menu__lien--actif"
(click)="fermerMenuSpecs()"
>
Plan de tâches
</a>
</div>
</div>
}
</div>
<!-- Avatar utilisateur (initiales), poussé tout à droite ; le sous-menu s'ouvre au
survol (moins de clics) et reste utilisable au clic (tactile, clavier). -->
<div class="avatar-menu" (mouseenter)="ouvrirMenuAvatar()" (mouseleave)="fermerMenuAvatar()">
<button
type="button"
class="avatar-menu__bouton"
[attr.aria-expanded]="menuAvatarOuvert()"
aria-haspopup="true"
[attr.aria-label]="'Menu utilisateur — ' + nomComplet()"
(click)="basculerMenuAvatar()"
>
{{ initiales() }}
</button>
@if (menuAvatarOuvert()) {
<div class="avatar-menu__panneau" role="menu">
<div class="avatar-menu__panneau-contenu">
<p class="avatar-menu__identite">{{ nomComplet() }}</p>
<a
class="avatar-menu__lien"
role="menuitem"
routerLink="/parametrage"
(click)="fermerMenuAvatar()"
>
Paramétrage
</a>
<a
class="avatar-menu__lien"
role="menuitem"
routerLink="/maintenance"
(click)="fermerMenuAvatar()"
>
Maintenance
</a>
<button
type="button"
class="avatar-menu__lien avatar-menu__lien--bouton"
role="menuitem"
(click)="seDeconnecter()"
>
Se déconnecter
</button>
</div>
</div>
}
</div>
</nav> </nav>
}
<main> <main>
<router-outlet></router-outlet> <router-outlet></router-outlet>
+155 -5
View File
@@ -16,19 +16,19 @@
border-bottom: 1px solid #23282d; border-bottom: 1px solid #23282d;
z-index: 500; z-index: 500;
// Logo SchotzIAD en tête de barre (icône seule) : accès rapide à la consultation. // Logo SchotzIAD (icône + texte, format horizontal) en tête de barre : accès
// Fond blanc localisé pour faire ressortir le logo sur la barre anthracite. // rapide à la consultation. Fond blanc localisé pour faire ressortir le logo
// sur la barre anthracite.
&__logo { &__logo {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.25rem; padding: 0.3rem 0.6rem;
margin-right: 0.5rem; margin-right: 0.75rem;
background: #fff; background: #fff;
border-radius: 6px; border-radius: 6px;
img { img {
display: block; display: block;
border-radius: 4px;
} }
&:hover { &:hover {
@@ -57,6 +57,156 @@
} }
} }
// Menu « Specs » + sous-menu, dans le flux normal de la barre (aligné à gauche).
.specs-menu {
position: relative;
&__bouton {
// Hérite de .nav-bar__lien (survol orange, actif bleu) via la classe combinée
// dans le template ; on ajoute juste le comportement de bouton natif neutre.
border: none;
background: transparent;
font: inherit;
cursor: pointer;
&--actif {
background: var(--couleur-orange);
color: #fff;
}
}
&__panneau {
// Colle le panneau au bouton (pas d'espace mort) : une marge y séparerait
// visuellement les deux zones et casserait le survol continu lors de la
// descente de la souris vers le panneau (mouseleave prématuré).
position: absolute;
top: 100%;
left: 0;
// Espace visuel avant le panneau réel (::before), qui fait pourtant partie
// de la zone de survol continue (contrairement à une simple marge).
padding-top: 0.5rem;
min-width: 200px;
z-index: 600;
}
&__panneau-contenu {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
padding: 0.5rem 0;
}
&__lien {
display: block;
width: 100%;
padding: 0.55rem 1rem;
color: #33475b;
font-size: 0.9rem;
text-align: left;
text-decoration: none;
cursor: pointer;
&:hover {
background: #f1f5f9;
color: var(--couleur-bleu);
}
&--actif {
background: var(--couleur-bleu);
color: #fff;
&:hover {
color: #fff;
}
}
}
}
// Avatar utilisateur + sous-menu, poussé tout à droite de la barre.
.avatar-menu {
position: relative;
margin-left: auto; // repousse l'avatar à l'extrémité droite de la nav-bar
&__bouton {
display: flex;
align-items: center;
justify-content: center;
width: 2.2rem;
height: 2.2rem;
border: none;
border-radius: 50%;
background: var(--couleur-bleu);
color: #fff;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
&:hover {
background: var(--couleur-orange);
}
}
&__panneau {
// Colle le panneau au bouton (pas d'espace mort) : une marge y séparerait
// visuellement les deux zones et casserait le survol continu lors de la
// descente de la souris vers le panneau (mouseleave prématuré).
position: absolute;
top: 100%;
right: 0;
// Espace visuel avant le panneau réel, qui fait pourtant partie de la zone
// de survol continue (contrairement à une simple marge).
padding-top: 0.5rem;
min-width: 200px;
z-index: 600;
}
&__panneau-contenu {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
padding: 0.5rem 0;
}
&__identite {
margin: 0;
padding: 0.5rem 1rem 0.6rem;
font-weight: 700;
color: var(--couleur-anthracite);
border-bottom: 1px solid #eef1f4;
}
&__lien {
display: block;
width: 100%;
padding: 0.55rem 1rem;
border: none;
background: none;
color: #33475b;
font-size: 0.9rem;
text-align: left;
text-decoration: none;
cursor: pointer;
&:hover {
background: #f1f5f9;
color: var(--couleur-bleu);
}
&--bouton {
font: inherit;
color: #b3261e;
&:hover {
background: #fdecea;
color: #b3261e;
}
}
}
}
/* Le contenu routé occupe la hauteur restante sous le menu et défile si nécessaire. /* Le contenu routé occupe la hauteur restante sous le menu et défile si nécessaire.
Les vues courtes gardent leur hauteur naturelle (aucun flex:1 imposé aux enfants). */ Les vues courtes gardent leur hauteur naturelle (aucun flex:1 imposé aux enfants). */
main { main {
+140 -2
View File
@@ -1,5 +1,20 @@
import { Component } from '@angular/core'; import { Component, HostListener, computed, effect, inject, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { catchError, filter, map, of, startWith } from 'rxjs';
import { AuthService } from './core/services/auth.service';
import { ProfilService } from './core/services/profil.service';
/**
* Chemins des pages PUBLIQUES (sans garde) : la barre de navigation ne doit
* jamais s'y afficher, même si un jeton résiduel (expiré/invalide) est encore
* présent en localStorage — seule la validité RÉELLE de la session (vérifiée
* par authGuard via /auth/moi) autorise l'accès aux pages protégées, mais la
* présence locale du jeton ne suffit pas à garantir qu'on n'est pas sur une de
* ces pages (aucun appel HTTP protégé n'y est fait pour purger un jeton périmé).
*/
const PAGES_PUBLIQUES = ['/login', '/register', '/forgot-password', '/reset-password'];
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -10,4 +25,127 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
}) })
export class AppComponent { export class AppComponent {
title = 'schotziad-km-tracker'; title = 'schotziad-km-tracker';
private readonly authService = inject(AuthService);
private readonly profilService = inject(ProfilService);
private readonly router = inject(Router);
/** Chemin de la route active, mis à jour à chaque navigation terminée. */
private readonly urlCourante = toSignal(
this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
map((e) => e.urlAfterRedirects),
startWith(this.router.url)
),
{ initialValue: this.router.url }
);
/** Vrai sur une page publique (login, register, forgot/reset-password). */
private readonly estPagePublique = computed(() =>
PAGES_PUBLIQUES.some((chemin) => this.urlCourante().startsWith(chemin))
);
/**
* Affiche la barre de navigation UNIQUEMENT si un jeton est présent ET que la
* route courante n'est pas une page publique. Ce deuxième critère est
* nécessaire : un jeton résiduel expiré/invalide peut rester en localStorage
* sans qu'aucun appel HTTP protégé ne le purge sur les pages publiques.
*/
readonly estAuthentifie = computed(
() => this.authService.estAuthentifie() && !this.estPagePublique()
);
/** Prénom / nom de l'utilisateur, chargés une fois authentifié (pour l'avatar). */
private readonly prenom = signal<string | null>(null);
private readonly nom = signal<string | null>(null);
/** État d'ouverture du sous-menu de l'avatar. */
readonly menuAvatarOuvert = signal(false);
/** État d'ouverture du sous-menu « Specs » (Exigences / Conception / Tâches). */
readonly menuSpecsOuvert = signal(false);
/** Initiales affichées dans l'avatar : 1ère lettre du prénom + 1ère lettre du nom. */
readonly initiales = computed(() => {
const p = this.prenom()?.trim().charAt(0) ?? '';
const n = this.nom()?.trim().charAt(0) ?? '';
return (p + n).toUpperCase() || '?';
});
/** Nom complet affiché en tête du sous-menu (« Prénom Nom »). */
readonly nomComplet = computed(() => {
const valeurs = [this.prenom(), this.nom()].filter((v) => !!v?.trim());
return valeurs.length > 0 ? valeurs.join(' ') : 'Utilisateur';
});
constructor() {
// Charge le profil (prénom/nom pour l'avatar) dès que l'utilisateur est authentifié.
effect(() => {
if (this.estAuthentifie()) {
this.profilService
.getProfil()
.pipe(catchError(() => of(null)))
.subscribe((profil) => {
this.prenom.set(profil?.prenom ?? null);
this.nom.set(profil?.nom ?? null);
});
} else {
this.prenom.set(null);
this.nom.set(null);
this.menuAvatarOuvert.set(false);
this.menuSpecsOuvert.set(false);
}
});
}
/** Ouvre le sous-menu de l'avatar (survol de la souris). */
ouvrirMenuAvatar(): void {
this.menuAvatarOuvert.set(true);
}
/** Ouvre / ferme le sous-menu de l'avatar (clic, utile au tactile/clavier). */
basculerMenuAvatar(): void {
this.menuAvatarOuvert.update((v) => !v);
}
/** Ferme le sous-menu (sélection d'une option, ou clic extérieur). */
fermerMenuAvatar(): void {
this.menuAvatarOuvert.set(false);
}
/** Déconnexion depuis le sous-menu. */
seDeconnecter(): void {
this.fermerMenuAvatar();
this.authService.logout();
}
/** Ouvre le sous-menu « Specs » (survol de la souris). */
ouvrirMenuSpecs(): void {
this.menuSpecsOuvert.set(true);
}
/** Ouvre / ferme le sous-menu « Specs » (clic, utile au tactile/clavier). */
basculerMenuSpecs(): void {
this.menuSpecsOuvert.update((v) => !v);
}
/** Ferme le sous-menu « Specs » (sélection d'un document, ou clic extérieur). */
fermerMenuSpecs(): void {
this.menuSpecsOuvert.set(false);
}
/**
* Ferme les sous-menus (avatar, Specs) au clic en dehors de leur bloc respectif
* (bouton + panneau déroulant).
*/
@HostListener('document:click', ['$event'])
onClicDocument(event: MouseEvent): void {
const cible = event.target as HTMLElement;
if (this.menuAvatarOuvert() && !cible.closest('.avatar-menu')) {
this.fermerMenuAvatar();
}
if (this.menuSpecsOuvert() && !cible.closest('.specs-menu')) {
this.fermerMenuSpecs();
}
}
} }
+28 -7
View File
@@ -10,7 +10,9 @@ import { BaremeComponent } from './features/bareme/bareme.component';
import { CarnetAdressesComponent } from './features/carnet-adresses/carnet-adresses.component'; import { CarnetAdressesComponent } from './features/carnet-adresses/carnet-adresses.component';
import { ConsultationJourComponent } from './features/consultation-jour/consultation-jour.component'; import { ConsultationJourComponent } from './features/consultation-jour/consultation-jour.component';
import { MontantsReglesComponent } from './features/montants-regles/montants-regles.component'; import { MontantsReglesComponent } from './features/montants-regles/montants-regles.component';
import { MaintenanceComponent } from './features/maintenance/maintenance.component';
import { OnboardingComponent } from './features/onboarding/onboarding.component'; import { OnboardingComponent } from './features/onboarding/onboarding.component';
import { ParametrageComponent } from './features/parametrage/parametrage.component';
import { ProfilComponent } from './features/profil/profil.component'; import { ProfilComponent } from './features/profil/profil.component';
import { ResumeAnnuelComponent } from './features/resume-annuel/resume-annuel.component'; import { ResumeAnnuelComponent } from './features/resume-annuel/resume-annuel.component';
import { ResumeMensuelComponent } from './features/resume-mensuel/resume-mensuel.component'; import { ResumeMensuelComponent } from './features/resume-mensuel/resume-mensuel.component';
@@ -24,15 +26,20 @@ import { VehiculesComponent } from './features/vehicules/vehicules.component';
* `reset-password`. Elles permettent à un utilisateur non authentifié de * `reset-password`. Elles permettent à un utilisateur non authentifié de
* s'inscrire, se connecter ou réinitialiser son mot de passe (onboarding Etape 1). * s'inscrire, se connecter ou réinitialiser son mot de passe (onboarding Etape 1).
* - Routes PROTEGEES par `authGuard` seul : `onboarding` (Etape 2, saisie du profil * - Routes PROTEGEES par `authGuard` seul : `onboarding` (Etape 2, saisie du profil
* et du premier véhicule — comportement inchangé), `profil` et `vehicules`. * et du premier véhicule — comportement inchangé).
* `authGuard` est vérifiée en premier ; on n'y applique pas `profilConfigureGuard` * `authGuard` est vérifiée en premier ; on n'y applique pas `profilConfigureGuard`
* pour ne pas piéger un utilisateur partiellement configuré. * pour ne pas piéger un utilisateur partiellement configuré.
* - `parametrage` (menu latéral + routes enfants `compte` / `vehicules` / `bareme`,
* accessible depuis le sous-menu de l'avatar utilisateur) : `parametrage` lui-même
* et ses enfants `compte` / `vehicules` ne portent que `authGuard` (mêmes raisons
* que ci-dessus) ; seul l'enfant `bareme` ajoute `profilConfigureGuard`.
* - Routes de données PROTEGEES par `[authGuard, profilConfigureGuard]` : * - Routes de données PROTEGEES par `[authGuard, profilConfigureGuard]` :
* `consultation`, `carnet-adresses`, `resume-mensuel`, `resume-annuel`, `bareme`, * `consultation`, `carnet-adresses`, `resume-mensuel`, `resume-annuel`,
* `montants-regles`. L'authentification est contrôlée d'abord, puis la * `montants-regles`. L'authentification est contrôlée d'abord, puis la
* configuration du profil (redirection vers `/onboarding` si `estConfigure = false`). * configuration du profil (redirection vers `/onboarding` si `estConfigure = false`).
* *
* Redirections : `''` -> `consultation` et wildcard `**` -> `consultation`. * Redirections : `''` -> `consultation`, `parametrage` -> `parametrage/compte`,
* et wildcard `**` -> `consultation`.
*/ */
export const routes: Routes = [ export const routes: Routes = [
{ path: '', redirectTo: 'consultation', pathMatch: 'full' }, { path: '', redirectTo: 'consultation', pathMatch: 'full' },
@@ -43,10 +50,9 @@ export const routes: Routes = [
{ path: 'forgot-password', component: ForgotPasswordComponent }, { path: 'forgot-password', component: ForgotPasswordComponent },
{ path: 'reset-password', component: ResetPasswordComponent }, { path: 'reset-password', component: ResetPasswordComponent },
// Routes PROTEGEES par authGuard seul (Etape 2 de l'onboarding + profil / véhicules) // Routes PROTEGEES par authGuard seul (Etape 2 de l'onboarding)
{ path: 'onboarding', component: OnboardingComponent, canActivate: [authGuard] }, { path: 'onboarding', component: OnboardingComponent, canActivate: [authGuard] },
{ path: 'profil', component: ProfilComponent, canActivate: [authGuard] }, { path: 'maintenance', component: MaintenanceComponent, canActivate: [authGuard] },
{ path: 'vehicules', component: VehiculesComponent, canActivate: [authGuard] },
// Documents de spécification (lecture seule) — protégés par authGuard. // Documents de spécification (lecture seule) — protégés par authGuard.
{ path: 'specs', redirectTo: 'specs/requirements', pathMatch: 'full' }, { path: 'specs', redirectTo: 'specs/requirements', pathMatch: 'full' },
@@ -58,12 +64,27 @@ export const routes: Routes = [
canActivate: [authGuard] canActivate: [authGuard]
}, },
// Paramétrage : menu latéral (Compte, Véhicules, Barème) + contenu en routes enfants.
// - 'compte' et 'vehicules' : authGuard SEUL (pas profilConfigureGuard, pour ne pas
// piéger un utilisateur dont le profil n'est pas encore configuré).
// - 'bareme' : authGuard + profilConfigureGuard (comportement inchangé).
{
path: 'parametrage',
component: ParametrageComponent,
canActivate: [authGuard],
children: [
{ path: '', redirectTo: 'compte', pathMatch: 'full' },
{ path: 'compte', component: ProfilComponent },
{ path: 'vehicules', component: VehiculesComponent },
{ path: 'bareme', component: BaremeComponent, canActivate: [profilConfigureGuard] }
]
},
// Routes de données PROTEGEES : authGuard d'abord, puis profilConfigureGuard // Routes de données PROTEGEES : authGuard d'abord, puis profilConfigureGuard
{ path: 'consultation', component: ConsultationJourComponent, canActivate: [authGuard, profilConfigureGuard] }, { path: 'consultation', component: ConsultationJourComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [authGuard, profilConfigureGuard] }, { path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'resume-mensuel', component: ResumeMensuelComponent, canActivate: [authGuard, profilConfigureGuard] }, { path: 'resume-mensuel', component: ResumeMensuelComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'resume-annuel', component: ResumeAnnuelComponent, canActivate: [authGuard, profilConfigureGuard] }, { path: 'resume-annuel', component: ResumeAnnuelComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'bareme', component: BaremeComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'montants-regles', component: MontantsReglesComponent, canActivate: [authGuard, profilConfigureGuard] }, { path: 'montants-regles', component: MontantsReglesComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: '**', redirectTo: 'consultation' } { path: '**', redirectTo: 'consultation' }
@@ -23,3 +23,13 @@ export interface ResetPasswordDto {
token: string; token: string;
nouveauMotDePasse: string; nouveauMotDePasse: string;
} }
/**
* Changement de mot de passe par un utilisateur authentifié (page Compte utilisateur) —
* miroir de ChangePasswordDto. Contrairement à ResetPasswordDto (jeton reçu par email),
* exige l'ancien mot de passe.
*/
export interface ChangePasswordDto {
motDePasseActuel: string;
nouveauMotDePasse: string;
}
+10 -1
View File
@@ -4,7 +4,7 @@ import { Router } from '@angular/router';
import { Observable, tap } from 'rxjs'; import { Observable, tap } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { LoginDto, LoginResult, RegisterDto, ResetPasswordDto } from '../models'; import { ChangePasswordDto, LoginDto, LoginResult, RegisterDto, ResetPasswordDto } from '../models';
/** Clé de stockage du JWT dans localStorage. */ /** Clé de stockage du JWT dans localStorage. */
const CLE_TOKEN = 'auth_token'; const CLE_TOKEN = 'auth_token';
@@ -50,6 +50,15 @@ export class AuthService {
return this.http.post<void>(`${this.baseUrl}/reset-password`, dto); return this.http.post<void>(`${this.baseUrl}/reset-password`, dto);
} }
/**
* Change le mot de passe d'un utilisateur authentifié (POST /auth/change-password,
* page Compte utilisateur). Contrairement à resetPassword (jeton reçu par email),
* exige l'ancien mot de passe. Le jeton Bearer est ajouté par l'authInterceptor.
*/
changePassword(dto: ChangePasswordDto): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/change-password`, dto);
}
/** Indique si un compte existe déjà (GET /auth/compte-existe). */ /** Indique si un compte existe déjà (GET /auth/compte-existe). */
compteExiste(): Observable<{ existe: boolean }> { compteExiste(): Observable<{ existe: boolean }> {
return this.http.get<{ existe: boolean }>(`${this.baseUrl}/compte-existe`); return this.http.get<{ existe: boolean }>(`${this.baseUrl}/compte-existe`);
@@ -71,8 +71,8 @@ h1 {
font-size: 0.95rem; font-size: 0.95rem;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: var(--couleur-bleu-fonce); background: var(--couleur-orange);
border-color: var(--couleur-bleu-fonce); border-color: var(--couleur-orange);
} }
} }
@@ -32,14 +32,13 @@
<div class="champ"> <div class="champ">
<label for="motDePasse">Mot de passe</label> <label for="motDePasse">Mot de passe</label>
<input <app-password-input
id="motDePasse" id="motDePasse"
type="password"
formControlName="motDePasse" formControlName="motDePasse"
autocomplete="current-password" autocomplete="current-password"
[attr.aria-invalid]="form.get('motDePasse')?.invalid && form.get('motDePasse')?.touched" [attr.aria-invalid]="form.get('motDePasse')?.invalid && form.get('motDePasse')?.touched"
aria-describedby="motDePasse-erreur" aria-describedby="motDePasse-erreur"
/> ></app-password-input>
@if (form.get('motDePasse')?.touched && form.get('motDePasse')?.invalid) { @if (form.get('motDePasse')?.touched && form.get('motDePasse')?.invalid) {
<p id="motDePasse-erreur" class="erreur" role="alert"> <p id="motDePasse-erreur" class="erreur" role="alert">
@if (form.get('motDePasse')?.hasError('required')) { @if (form.get('motDePasse')?.hasError('required')) {
@@ -66,8 +66,8 @@ h1 {
font-size: 0.95rem; font-size: 0.95rem;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: var(--couleur-bleu-fonce); background: var(--couleur-orange);
border-color: var(--couleur-bleu-fonce); border-color: var(--couleur-orange);
} }
} }
@@ -5,6 +5,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { PasswordInputComponent } from '../../../shared/components/password-input/password-input.component';
import { NotificationService } from '../../../core/services/notification.service'; import { NotificationService } from '../../../core/services/notification.service';
import { LoginDto } from '../../../core/models'; import { LoginDto } from '../../../core/models';
@@ -25,7 +26,7 @@ import { LoginDto } from '../../../core/models';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, RouterLink], imports: [CommonModule, ReactiveFormsModule, RouterLink, PasswordInputComponent],
templateUrl: './login.component.html', templateUrl: './login.component.html',
styleUrl: './login.component.scss' styleUrl: './login.component.scss'
}) })
@@ -32,14 +32,13 @@
<div class="champ"> <div class="champ">
<label for="motDePasse">Mot de passe</label> <label for="motDePasse">Mot de passe</label>
<input <app-password-input
id="motDePasse" id="motDePasse"
type="password"
formControlName="motDePasse" formControlName="motDePasse"
autocomplete="new-password" autocomplete="new-password"
[attr.aria-invalid]="form.get('motDePasse')?.invalid && form.get('motDePasse')?.touched" [attr.aria-invalid]="form.get('motDePasse')?.invalid && form.get('motDePasse')?.touched"
aria-describedby="motDePasse-erreur" aria-describedby="motDePasse-erreur"
/> ></app-password-input>
@if (form.get('motDePasse')?.touched && form.get('motDePasse')?.invalid) { @if (form.get('motDePasse')?.touched && form.get('motDePasse')?.invalid) {
<p id="motDePasse-erreur" class="erreur" role="alert"> <p id="motDePasse-erreur" class="erreur" role="alert">
@if (form.get('motDePasse')?.hasError('required')) { @if (form.get('motDePasse')?.hasError('required')) {
@@ -53,9 +52,8 @@
<div class="champ"> <div class="champ">
<label for="confirmationMotDePasse">Confirmation du mot de passe</label> <label for="confirmationMotDePasse">Confirmation du mot de passe</label>
<input <app-password-input
id="confirmationMotDePasse" id="confirmationMotDePasse"
type="password"
formControlName="confirmationMotDePasse" formControlName="confirmationMotDePasse"
autocomplete="new-password" autocomplete="new-password"
[attr.aria-invalid]=" [attr.aria-invalid]="
@@ -63,7 +61,7 @@
form.get('confirmationMotDePasse')?.touched form.get('confirmationMotDePasse')?.touched
" "
aria-describedby="confirmation-erreur" aria-describedby="confirmation-erreur"
/> ></app-password-input>
@if (form.get('confirmationMotDePasse')?.touched) { @if (form.get('confirmationMotDePasse')?.touched) {
@if (form.get('confirmationMotDePasse')?.hasError('required')) { @if (form.get('confirmationMotDePasse')?.hasError('required')) {
<p id="confirmation-erreur" class="erreur" role="alert"> <p id="confirmation-erreur" class="erreur" role="alert">
@@ -66,8 +66,8 @@ h1 {
font-size: 0.95rem; font-size: 0.95rem;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: var(--couleur-bleu-fonce); background: var(--couleur-orange);
border-color: var(--couleur-bleu-fonce); border-color: var(--couleur-orange);
} }
} }
@@ -12,6 +12,7 @@ import {
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { PasswordInputComponent } from '../../../shared/components/password-input/password-input.component';
import { NotificationService } from '../../../core/services/notification.service'; import { NotificationService } from '../../../core/services/notification.service';
import { RegisterDto } from '../../../core/models'; import { RegisterDto } from '../../../core/models';
@@ -47,7 +48,7 @@ function motsDePasseEgauxValidator(groupe: AbstractControl): ValidationErrors |
@Component({ @Component({
selector: 'app-register', selector: 'app-register',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule], imports: [CommonModule, ReactiveFormsModule, PasswordInputComponent],
templateUrl: './register.component.html', templateUrl: './register.component.html',
styleUrl: './register.component.scss' styleUrl: './register.component.scss'
}) })
@@ -19,16 +19,15 @@
<div class="champ"> <div class="champ">
<label for="nouveauMotDePasse">Nouveau mot de passe</label> <label for="nouveauMotDePasse">Nouveau mot de passe</label>
<input <app-password-input
id="nouveauMotDePasse" id="nouveauMotDePasse"
type="password"
formControlName="nouveauMotDePasse" formControlName="nouveauMotDePasse"
autocomplete="new-password" autocomplete="new-password"
[attr.aria-invalid]=" [attr.aria-invalid]="
form.get('nouveauMotDePasse')?.invalid && form.get('nouveauMotDePasse')?.touched form.get('nouveauMotDePasse')?.invalid && form.get('nouveauMotDePasse')?.touched
" "
aria-describedby="nouveauMotDePasse-erreur" aria-describedby="nouveauMotDePasse-erreur"
/> ></app-password-input>
@if (form.get('nouveauMotDePasse')?.touched && form.get('nouveauMotDePasse')?.invalid) { @if (form.get('nouveauMotDePasse')?.touched && form.get('nouveauMotDePasse')?.invalid) {
<p id="nouveauMotDePasse-erreur" class="erreur" role="alert"> <p id="nouveauMotDePasse-erreur" class="erreur" role="alert">
@if (form.get('nouveauMotDePasse')?.hasError('required')) { @if (form.get('nouveauMotDePasse')?.hasError('required')) {
@@ -42,9 +41,8 @@
<div class="champ"> <div class="champ">
<label for="confirmation">Confirmer le mot de passe</label> <label for="confirmation">Confirmer le mot de passe</label>
<input <app-password-input
id="confirmation" id="confirmation"
type="password"
formControlName="confirmation" formControlName="confirmation"
autocomplete="new-password" autocomplete="new-password"
[attr.aria-invalid]=" [attr.aria-invalid]="
@@ -52,7 +50,7 @@
(form.get('confirmation')?.invalid || form.hasError('motsDePasseDifferents'))) (form.get('confirmation')?.invalid || form.hasError('motsDePasseDifferents')))
" "
aria-describedby="confirmation-erreur" aria-describedby="confirmation-erreur"
/> ></app-password-input>
@if ( @if (
form.get('confirmation')?.touched && form.get('confirmation')?.touched &&
(form.get('confirmation')?.invalid || form.hasError('motsDePasseDifferents')) (form.get('confirmation')?.invalid || form.hasError('motsDePasseDifferents'))
@@ -66,8 +66,8 @@ h1 {
font-size: 0.95rem; font-size: 0.95rem;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: var(--couleur-bleu-fonce); background: var(--couleur-orange);
border-color: var(--couleur-bleu-fonce); border-color: var(--couleur-orange);
} }
} }
@@ -12,6 +12,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { PasswordInputComponent } from '../../../shared/components/password-input/password-input.component';
import { NotificationService } from '../../../core/services/notification.service'; import { NotificationService } from '../../../core/services/notification.service';
import { ResetPasswordDto } from '../../../core/models'; import { ResetPasswordDto } from '../../../core/models';
@@ -43,7 +44,7 @@ function egaliteMotsDePasse(control: AbstractControl): ValidationErrors | null {
@Component({ @Component({
selector: 'app-reset-password', selector: 'app-reset-password',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, RouterLink], imports: [CommonModule, ReactiveFormsModule, RouterLink, PasswordInputComponent],
templateUrl: './reset-password.component.html', templateUrl: './reset-password.component.html',
styleUrl: './reset-password.component.scss' styleUrl: './reset-password.component.scss'
}) })
@@ -0,0 +1,24 @@
<section class="maintenance-page">
<h1>Maintenance</h1>
<section class="maintenance">
<h2>Recalcul des distances</h2>
<p class="aide">
Recalcule la distance <strong>par la route</strong> des trajets qui n'ont pas encore
d'itinéraire routier (calculés à vol d'oiseau). Les trajets déjà corrects ne sont pas
resollicités, ce qui limite les appels à OpenRouteService.
</p>
<button
type="button"
class="btn-secondaire"
(click)="recalculerDistances()"
[disabled]="recalculEnCours()"
>
@if (recalculEnCours()) {
Recalcul en cours…
} @else {
Recalculer les distances des trajets
}
</button>
</section>
</section>
@@ -0,0 +1,41 @@
.maintenance-page {
padding: 1.5rem;
max-width: 640px;
margin: 0 auto;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.maintenance {
h2 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
}
.aide {
color: #777;
margin: 0 0 1rem;
}
.btn-secondaire {
padding: 0.55rem 1.25rem;
background: #fff;
color: var(--couleur-bleu, #0074b8);
border: 1px solid var(--couleur-bleu, #0074b8);
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
&:hover:not(:disabled) {
background: #eef6fb;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
@@ -0,0 +1,57 @@
import { CommonModule } from '@angular/common';
import { Component, inject, signal } from '@angular/core';
import { NotificationService } from '../../core/services/notification.service';
import { TrajetService } from '../../core/services/trajet.service';
/**
* Page de maintenance technique : recalcul de la distance routière (via ORS) des
* trajets qui n'ont pas encore d'itinéraire routier calculé (fallback vol d'oiseau).
*
* Extrait de l'ancienne page Profil, désormais accessible depuis le sous-menu de
* l'avatar utilisateur (entre « Paramétrage » et « Se déconnecter »), route `/maintenance`.
*/
@Component({
selector: 'app-maintenance',
standalone: true,
imports: [CommonModule],
templateUrl: './maintenance.component.html',
styleUrl: './maintenance.component.scss'
})
export class MaintenanceComponent {
private readonly trajetService = inject(TrajetService);
private readonly notification = inject(NotificationService);
/** true pendant le recalcul des distances (désactive le bouton dédié). */
readonly recalculEnCours = signal(false);
/**
* Recalcule la distance routière de tous les trajets via ORS. Utile après
* correction de la configuration ORS pour remplacer d'anciennes distances
* calculées à vol d'oiseau par la distance par la route.
*/
recalculerDistances(): void {
if (this.recalculEnCours()) {
return;
}
this.recalculEnCours.set(true);
this.trajetService.recalculerDistances().subscribe({
next: (resultat) => {
this.recalculEnCours.set(false);
if (resultat.trajetsRecalcules === 0) {
this.notification.success(
'Toutes les distances sont déjà à jour (itinéraire routier calculé). Aucun appel ORS nécessaire.'
);
} else {
this.notification.success(
`Distances recalculées pour ${resultat.trajetsRecalcules} trajet(s).`
);
}
},
error: () => {
this.recalculEnCours.set(false);
this.notification.error('Le recalcul des distances a échoué.');
}
});
}
}
@@ -0,0 +1,34 @@
<section class="parametrage" aria-label="Paramétrage">
<!-- Menu latéral : une entrée par donnée paramétrable. -->
<aside class="parametrage__menu" aria-label="Données paramétrables">
<h2 class="parametrage__menu-titre">Paramétrage</h2>
<nav class="parametrage__liens">
<a
class="parametrage__lien"
routerLink="/parametrage/compte"
routerLinkActive="parametrage__lien--actif"
>
Compte
</a>
<a
class="parametrage__lien"
routerLink="/parametrage/vehicules"
routerLinkActive="parametrage__lien--actif"
>
Véhicules
</a>
<a
class="parametrage__lien"
routerLink="/parametrage/bareme"
routerLinkActive="parametrage__lien--actif"
>
Barème
</a>
</nav>
</aside>
<!-- Contenu : composant de la donnée paramétrable sélectionnée. -->
<div class="parametrage__contenu">
<router-outlet></router-outlet>
</div>
</section>
@@ -0,0 +1,57 @@
.parametrage {
display: flex;
gap: 1.5rem;
max-width: 1100px;
margin: 0 auto;
padding: 1rem;
align-items: flex-start;
&__menu {
flex: 0 0 220px;
position: sticky;
top: 1rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.75rem;
}
&__menu-titre {
margin: 0 0 0.5rem;
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #64748b;
}
&__liens {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
&__lien {
display: block;
padding: 0.5rem 0.6rem;
border-radius: 6px;
color: var(--couleur-anthracite, #1f2937);
text-decoration: none;
font-size: 0.9rem;
&:hover {
background: #eef2f7;
}
&--actif {
background: var(--couleur-bleu, #2563eb);
color: #fff;
font-weight: 600;
}
}
&__contenu {
flex: 1 1 auto;
min-width: 0;
}
}
@@ -0,0 +1,20 @@
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
/**
* `ParametrageComponent` — page de paramétrage : menu latéral des données
* paramétrables (Compte, Véhicules, Barème) et zone de contenu à droite qui
* affiche le composant correspondant via un `router-outlet` imbriqué (routes
* enfants `/parametrage/compte`, `/parametrage/vehicules`, `/parametrage/bareme`).
*
* Réutilise les composants existants (ProfilComponent, VehiculesComponent,
* BaremeComponent) sans les dupliquer ; seul le point d'entrée change.
*/
@Component({
selector: 'app-parametrage',
standalone: true,
imports: [RouterLink, RouterLinkActive, RouterOutlet],
templateUrl: './parametrage.component.html',
styleUrl: './parametrage.component.scss'
})
export class ParametrageComponent {}
@@ -1,5 +1,5 @@
<section class="profil"> <section class="profil">
<h1>Profil</h1> <h1>Compte utilisateur</h1>
<form [formGroup]="form" (ngSubmit)="enregistrer()" novalidate> <form [formGroup]="form" (ngSubmit)="enregistrer()" novalidate>
<div class="champ"> <div class="champ">
@@ -62,25 +62,67 @@
</div> </div>
</form> </form>
<!-- Maintenance : recalcul des distances routières via OpenRouteService. --> <!-- Changement de mot de passe (utilisateur authentifié). -->
<section class="maintenance"> <section class="mot-de-passe">
<h2>Maintenance</h2> <h2>Changer le mot de passe</h2>
<p class="aide">
Recalcule la distance <strong>par la route</strong> des trajets qui n'ont pas encore <form [formGroup]="formMotDePasse" (ngSubmit)="changerMotDePasse()" novalidate>
d'itinéraire routier (calculés à vol d'oiseau). Les trajets déjà corrects ne sont pas @if (erreurMotDePasse()) {
resollicités, ce qui limite les appels à OpenRouteService. <p class="erreur-globale" role="alert">{{ erreurMotDePasse() }}</p>
</p>
<button
type="button"
class="btn-secondaire"
(click)="recalculerDistances()"
[disabled]="recalculEnCours()"
>
@if (recalculEnCours()) {
Recalcul en cours…
} @else {
Recalculer les distances des trajets
} }
</button>
<div class="champ">
<label for="motDePasseActuel">Mot de passe actuel</label>
<app-password-input
id="motDePasseActuel"
formControlName="motDePasseActuel"
autocomplete="current-password"
></app-password-input>
@if (formMotDePasse.get('motDePasseActuel')?.touched && formMotDePasse.get('motDePasseActuel')?.invalid) {
<small class="erreur">Le mot de passe actuel est obligatoire.</small>
}
</div>
<div class="champ">
<label for="nouveauMotDePasse">Nouveau mot de passe</label>
<app-password-input
id="nouveauMotDePasse"
formControlName="nouveauMotDePasse"
autocomplete="new-password"
></app-password-input>
@if (formMotDePasse.get('nouveauMotDePasse')?.touched && formMotDePasse.get('nouveauMotDePasse')?.invalid) {
<small class="erreur">Le nouveau mot de passe doit comporter au moins 8 caractères.</small>
}
</div>
<div class="champ">
<label for="confirmationMotDePasse">Confirmer le nouveau mot de passe</label>
<app-password-input
id="confirmationMotDePasse"
formControlName="confirmationMotDePasse"
autocomplete="new-password"
></app-password-input>
@if (
formMotDePasse.get('confirmationMotDePasse')?.touched &&
(formMotDePasse.get('confirmationMotDePasse')?.invalid || formMotDePasse.hasError('motsDePasseDifferents'))
) {
@if (formMotDePasse.get('confirmationMotDePasse')?.hasError('required')) {
<small class="erreur">La confirmation est obligatoire.</small>
} @else if (formMotDePasse.hasError('motsDePasseDifferents')) {
<small class="erreur">Les mots de passe ne correspondent pas.</small>
}
}
</div>
<div class="actions">
<button type="submit" class="btn-principal" [disabled]="formMotDePasse.invalid || changementMotDePasseEnCours()">
@if (changementMotDePasseEnCours()) {
Modification…
} @else {
Changer le mot de passe
}
</button>
</div>
</form>
</section> </section>
</section> </section>
@@ -96,3 +96,23 @@ h1 {
opacity: 0.55; opacity: 0.55;
cursor: not-allowed; cursor: not-allowed;
} }
.mot-de-passe {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e2e8f0;
h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
}
}
.erreur-globale {
color: #c62828;
background: #fdecea;
border: 1px solid #f5c6c2;
border-radius: 6px;
padding: 0.5rem 0.75rem;
margin-bottom: 1rem;
}
@@ -1,19 +1,42 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import {
AbstractControl,
FormBuilder,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
Validators
} from '@angular/forms';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { AuthService } from '../../core/services/auth.service';
import { PasswordInputComponent } from '../../shared/components/password-input/password-input.component';
import { BANService, BAN_MIN_CARACTERES } from '../../core/services/ban.service'; import { BANService, BAN_MIN_CARACTERES } from '../../core/services/ban.service';
import { NotificationService } from '../../core/services/notification.service'; import { NotificationService } from '../../core/services/notification.service';
import { ProfilService } from '../../core/services/profil.service'; import { ProfilService } from '../../core/services/profil.service';
import { TrajetService } from '../../core/services/trajet.service'; import { BANFeature, ChangePasswordDto, Profil, SaveProfil } from '../../core/models';
import { BANFeature, Profil, SaveProfil } from '../../core/models';
/** /**
* Page de profil utilisateur (Exigences 10.1, 10.2, 10.8). * Validateur de groupe : vérifie l'égalité du nouveau mot de passe et de sa
* confirmation (même pattern que RegisterComponent). La confirmation vide n'est
* pas signalée ici (le validateur `required` du contrôle s'en charge).
*/
function motsDePasseEgauxValidator(groupe: AbstractControl): ValidationErrors | null {
const nouveau = groupe.get('nouveauMotDePasse')?.value ?? '';
const confirmation = groupe.get('confirmationMotDePasse')?.value ?? '';
if (confirmation.length === 0) {
return null;
}
return nouveau === confirmation ? null : { motsDePasseDifferents: true };
}
/**
* Page « Compte utilisateur » (Exigences 10.1, 10.2, 10.8).
* *
* Formulaire réactif : * Formulaire d'identité réactif :
* - nom (1–100 car.), prénom (1–100 car.) : obligatoires * - nom (1–100 car.), prénom (1–100 car.) : obligatoires
* - matricule (max 8 car., limite de saisie stricte via `maxlength`) * - matricule (max 8 car., limite de saisie stricte via `maxlength`)
* - adresse de domicile (1–255 car., optionnelle) avec autocomplétion BAN * - adresse de domicile (1–255 car., optionnelle) avec autocomplétion BAN
@@ -21,11 +44,16 @@ import { BANFeature, Profil, SaveProfil } from '../../core/models';
* "Enregistrer" appelle `ProfilService.saveProfil`. Un avertissement est affiché * "Enregistrer" appelle `ProfilService.saveProfil`. Un avertissement est affiché
* si le géocodage de l'adresse a échoué (profil.domicileGeocode = false alors * si le géocodage de l'adresse a échoué (profil.domicileGeocode = false alors
* qu'une adresse est renseignée). * qu'une adresse est renseignée).
*
* Formulaire de changement de mot de passe (distinct) : mot de passe actuel +
* nouveau + confirmation, avec validateur de groupe garantissant l'égalité des
* deux derniers. Un mot de passe actuel incorrect (HTTP 400) est signalé sur le
* champ correspondant.
*/ */
@Component({ @Component({
selector: 'app-profil', selector: 'app-profil',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule], imports: [CommonModule, ReactiveFormsModule, PasswordInputComponent],
templateUrl: './profil.component.html', templateUrl: './profil.component.html',
styleUrl: './profil.component.scss' styleUrl: './profil.component.scss'
}) })
@@ -34,7 +62,7 @@ export class ProfilComponent implements OnInit, OnDestroy {
private readonly profilService = inject(ProfilService); private readonly profilService = inject(ProfilService);
private readonly banService = inject(BANService); private readonly banService = inject(BANService);
private readonly notification = inject(NotificationService); private readonly notification = inject(NotificationService);
private readonly trajetService = inject(TrajetService); private readonly authService = inject(AuthService);
/** Longueur maximale du matricule (limite de saisie stricte, Exigence 10.2). */ /** Longueur maximale du matricule (limite de saisie stricte, Exigence 10.2). */
readonly matriculeMaxLength = 8; readonly matriculeMaxLength = 8;
@@ -47,8 +75,14 @@ export class ProfilComponent implements OnInit, OnDestroy {
/** true pendant l'appel de sauvegarde (désactive le bouton). */ /** true pendant l'appel de sauvegarde (désactive le bouton). */
readonly enregistrement = signal(false); readonly enregistrement = signal(false);
/** true pendant le recalcul des distances (désactive le bouton dédié). */ /** Formulaire de changement de mot de passe. */
readonly recalculEnCours = signal(false); formMotDePasse!: FormGroup;
/** true pendant l'appel de changement de mot de passe (désactive le bouton dédié). */
readonly changementMotDePasseEnCours = signal(false);
/** Message d'erreur du changement de mot de passe, ou null si aucune erreur. */
readonly erreurMotDePasse = signal<string | null>(null);
private readonly domicileInput$ = new Subject<string>(); private readonly domicileInput$ = new Subject<string>();
private readonly destroy$ = new Subject<void>(); private readonly destroy$ = new Subject<void>();
@@ -61,6 +95,15 @@ export class ProfilComponent implements OnInit, OnDestroy {
adresseDomicile: ['', [Validators.maxLength(255)]] adresseDomicile: ['', [Validators.maxLength(255)]]
}); });
this.formMotDePasse = this.fb.group(
{
motDePasseActuel: ['', [Validators.required]],
nouveauMotDePasse: ['', [Validators.required, Validators.minLength(8)]],
confirmationMotDePasse: ['', [Validators.required]]
},
{ validators: motsDePasseEgauxValidator }
);
// Autocomplétion BAN (déclenchée dès 3 caractères, debounce 300 ms). // Autocomplétion BAN (déclenchée dès 3 caractères, debounce 300 ms).
this.domicileInput$ this.domicileInput$
.pipe( .pipe(
@@ -143,32 +186,35 @@ export class ProfilComponent implements OnInit, OnDestroy {
}); });
} }
/** /** Change le mot de passe via `AuthService.changePassword`. */
* Recalcule la distance routière de tous les trajets via ORS (action de maintenance). changerMotDePasse(): void {
* Utile après correction de la configuration ORS pour remplacer d'anciennes distances if (this.formMotDePasse.invalid || this.changementMotDePasseEnCours()) {
* calculées à vol d'oiseau par la distance par la route. this.formMotDePasse.markAllAsTouched();
*/
recalculerDistances(): void {
if (this.recalculEnCours()) {
return; return;
} }
this.recalculEnCours.set(true);
this.trajetService.recalculerDistances().subscribe({ const valeur = this.formMotDePasse.getRawValue();
next: (resultat) => { const dto: ChangePasswordDto = {
this.recalculEnCours.set(false); motDePasseActuel: valeur.motDePasseActuel ?? '',
if (resultat.trajetsRecalcules === 0) { nouveauMotDePasse: valeur.nouveauMotDePasse ?? ''
this.notification.success( };
'Toutes les distances sont déjà à jour (itinéraire routier calculé). Aucun appel ORS nécessaire.'
); this.erreurMotDePasse.set(null);
} else { this.changementMotDePasseEnCours.set(true);
this.notification.success( this.authService.changePassword(dto).subscribe({
`Distances recalculées pour ${resultat.trajetsRecalcules} trajet(s).` next: () => {
); this.changementMotDePasseEnCours.set(false);
} this.formMotDePasse.reset();
this.notification.success('Mot de passe modifié.');
}, },
error: () => { error: (err: HttpErrorResponse) => {
this.recalculEnCours.set(false); this.changementMotDePasseEnCours.set(false);
this.notification.error('Le recalcul des distances a échoué.'); const message =
err.status === 400
? 'Le mot de passe actuel est incorrect.'
: 'Le changement de mot de passe a échoué. Veuillez réessayer.';
this.erreurMotDePasse.set(message);
this.notification.error(message);
} }
}); });
} }
@@ -0,0 +1,36 @@
<div class="password-input">
<input
[id]="id"
[type]="visible() ? 'text' : 'password'"
[autocomplete]="autocomplete"
[attr.aria-describedby]="ariaDescribedby"
[attr.aria-invalid]="ariaInvalid"
[disabled]="disabled()"
[value]="value"
(input)="onInput($any($event.target).value)"
(blur)="onBlur()"
/>
<button
type="button"
class="password-input__bouton-oeil"
[attr.aria-label]="visible() ? 'Masquer le mot de passe' : 'Afficher le mot de passe'"
[attr.aria-pressed]="visible()"
[title]="visible() ? 'Masquer le mot de passe' : 'Afficher le mot de passe'"
(click)="basculerVisibilite()"
>
@if (visible()) {
<!-- Icône « œil barré » (mot de passe visible : cliquer pour masquer). -->
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M3 3l18 18" stroke-linecap="round" />
<path d="M10.58 10.58a2 2 0 0 0 2.83 2.83" stroke-linecap="round" />
<path d="M9.88 4.24A9.94 9.94 0 0 1 12 4c5 0 9 4 10 8-.31 1.14-.85 2.28-1.57 3.32M6.11 6.1C4.24 7.44 2.77 9.5 2 12c1 4 5 8 10 8 1.06 0 2.07-.18 3-.5" stroke-linecap="round" />
</svg>
} @else {
<!-- Icône « œil ouvert » (mot de passe masqué : cliquer pour afficher). -->
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M2 12s4-8 10-8 10 8 10 8-4 8-10 8-10-8-10-8Z" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="12" r="3" />
</svg>
}
</button>
</div>
@@ -0,0 +1,55 @@
// Style aligné sur les champs texte classiques des formulaires (ex. `.champ input`
// des pages d'authentification et de la page Compte utilisateur), afin que le rendu
// soit identique entre un champ de saisie normal et ce champ mot de passe.
.password-input {
position: relative;
display: flex;
width: 100%;
input {
flex: 1 1 auto;
width: 100%;
padding: 0.5rem;
// Réserve la place du bouton œil pour que le texte ne passe pas dessous.
padding-right: 2.4rem;
border: 1px solid #bbb;
border-radius: 6px;
font-size: 0.95rem;
font-family: inherit;
color: inherit;
&[aria-invalid='true'] {
border-color: #c62828;
}
&:disabled {
background: #f3f3f3;
cursor: not-allowed;
}
}
&__bouton-oeil {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2.2rem;
height: 100%;
border: none;
background: transparent;
color: #6b7280;
cursor: pointer;
border-radius: 0 6px 6px 0;
&:hover {
color: var(--couleur-bleu, #0074b8);
}
&:focus-visible {
outline: 2px solid var(--couleur-bleu, #0074b8);
outline-offset: -2px;
}
}
}
@@ -0,0 +1,91 @@
import { CommonModule } from '@angular/common';
import { Component, Input, forwardRef, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
/**
* `PasswordInputComponent` — champ de mot de passe avec bouton « afficher / masquer »
* (icône œil), réutilisable dans tous les formulaires de saisie de mot de passe.
*
* S'intègre nativement à Reactive Forms via `ControlValueAccessor` : s'utilise comme
* un `<input>` classique avec `formControlName` ou `[(ngModel)]`.
*
* ```html
* <app-password-input formControlName="motDePasse" autocomplete="new-password" />
* ```
*
* L'attribut `type` bascule entre `password` (par défaut) et `text` au clic sur le
* bouton œil ; l'état de visibilité est purement local à l'affichage (jamais transmis
* au FormControl, qui ne porte que la valeur saisie).
*/
@Component({
selector: 'app-password-input',
standalone: true,
imports: [CommonModule],
templateUrl: './password-input.component.html',
styleUrls: ['./password-input.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => PasswordInputComponent),
multi: true
}
]
})
export class PasswordInputComponent implements ControlValueAccessor {
/** Identifiant HTML du champ (pour l'association avec un `<label for="...">`). */
@Input() id = '';
/** Valeur de l'attribut `autocomplete` (ex. `current-password`, `new-password`). */
@Input() autocomplete = 'current-password';
/** Relaie `aria-describedby` vers l'`<input>` interne (association au message d'erreur). */
@Input('aria-describedby') ariaDescribedby: string | null = null;
/** Relaie `aria-invalid` vers l'`<input>` interne. */
@Input('aria-invalid') ariaInvalid: boolean | null = null;
/** Valeur courante du champ. */
value = '';
/** true si le mot de passe est actuellement affiché en clair. */
readonly visible = signal(false);
/** true si le champ est désactivé (état Reactive Forms). */
readonly disabled = signal(false);
private onChange: (valeur: string) => void = () => {};
private onTouched: () => void = () => {};
/** Bascule l'affichage du mot de passe en clair / masqué. */
basculerVisibilite(): void {
this.visible.update((v) => !v);
}
/** Répercute la saisie utilisateur vers le FormControl. */
onInput(valeur: string): void {
this.value = valeur;
this.onChange(valeur);
}
onBlur(): void {
this.onTouched();
}
// --- ControlValueAccessor ---
writeValue(valeur: string): void {
this.value = valeur ?? '';
}
registerOnChange(fn: (valeur: string) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
}