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
+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">
<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 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">
Carnet d'adresses
</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">
Montants réglés
</a>
@@ -29,10 +23,103 @@
Résumé annuel
</a>
<a class="nav-bar__lien" routerLink="/specs" routerLinkActive="nav-bar__lien--actif">
Specs
</a>
<!-- Menu « Specs » : sous-menu (Exigences / Conception technique / Plan de tâches)
qui s'ouvre au survol (comme l'avatar), utilisable aussi au clic. -->
<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>
}
<main>
<router-outlet></router-outlet>
+155 -5
View File
@@ -16,19 +16,19 @@
border-bottom: 1px solid #23282d;
z-index: 500;
// Logo SchotzIAD en tête de barre (icône seule) : accès rapide à la consultation.
// Fond blanc localisé pour faire ressortir le logo sur la barre anthracite.
// Logo SchotzIAD (icône + texte, format horizontal) en tête de barre : accès
// rapide à la consultation. Fond blanc localisé pour faire ressortir le logo
// sur la barre anthracite.
&__logo {
display: flex;
align-items: center;
padding: 0.25rem;
margin-right: 0.5rem;
padding: 0.3rem 0.6rem;
margin-right: 0.75rem;
background: #fff;
border-radius: 6px;
img {
display: block;
border-radius: 4px;
}
&: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.
Les vues courtes gardent leur hauteur naturelle (aucun flex:1 imposé aux enfants). */
main {
+140 -2
View File
@@ -1,5 +1,20 @@
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { Component, HostListener, computed, effect, inject, signal } from '@angular/core';
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({
selector: 'app-root',
@@ -10,4 +25,127 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
})
export class AppComponent {
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 { ConsultationJourComponent } from './features/consultation-jour/consultation-jour.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 { ParametrageComponent } from './features/parametrage/parametrage.component';
import { ProfilComponent } from './features/profil/profil.component';
import { ResumeAnnuelComponent } from './features/resume-annuel/resume-annuel.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
* 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
* 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`
* 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]` :
* `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
* 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 = [
{ path: '', redirectTo: 'consultation', pathMatch: 'full' },
@@ -43,10 +50,9 @@ export const routes: Routes = [
{ path: 'forgot-password', component: ForgotPasswordComponent },
{ 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: 'profil', component: ProfilComponent, canActivate: [authGuard] },
{ path: 'vehicules', component: VehiculesComponent, canActivate: [authGuard] },
{ path: 'maintenance', component: MaintenanceComponent, canActivate: [authGuard] },
// Documents de spécification (lecture seule) — protégés par authGuard.
{ path: 'specs', redirectTo: 'specs/requirements', pathMatch: 'full' },
@@ -58,12 +64,27 @@ export const routes: Routes = [
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
{ path: 'consultation', component: ConsultationJourComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'resume-mensuel', component: ResumeMensuelComponent, 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: '**', redirectTo: 'consultation' }
@@ -23,3 +23,13 @@ export interface ResetPasswordDto {
token: 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 { 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. */
const CLE_TOKEN = 'auth_token';
@@ -50,6 +50,15 @@ export class AuthService {
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). */
compteExiste(): Observable<{ existe: boolean }> {
return this.http.get<{ existe: boolean }>(`${this.baseUrl}/compte-existe`);
@@ -71,8 +71,8 @@ h1 {
font-size: 0.95rem;
&:hover:not(:disabled) {
background: var(--couleur-bleu-fonce);
border-color: var(--couleur-bleu-fonce);
background: var(--couleur-orange);
border-color: var(--couleur-orange);
}
}
@@ -32,14 +32,13 @@
<div class="champ">
<label for="motDePasse">Mot de passe</label>
<input
<app-password-input
id="motDePasse"
type="password"
formControlName="motDePasse"
autocomplete="current-password"
[attr.aria-invalid]="form.get('motDePasse')?.invalid && form.get('motDePasse')?.touched"
aria-describedby="motDePasse-erreur"
/>
></app-password-input>
@if (form.get('motDePasse')?.touched && form.get('motDePasse')?.invalid) {
<p id="motDePasse-erreur" class="erreur" role="alert">
@if (form.get('motDePasse')?.hasError('required')) {
@@ -66,8 +66,8 @@ h1 {
font-size: 0.95rem;
&:hover:not(:disabled) {
background: var(--couleur-bleu-fonce);
border-color: var(--couleur-bleu-fonce);
background: var(--couleur-orange);
border-color: var(--couleur-orange);
}
}
@@ -5,6 +5,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { Router, RouterLink } from '@angular/router';
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 { LoginDto } from '../../../core/models';
@@ -25,7 +26,7 @@ import { LoginDto } from '../../../core/models';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, RouterLink],
imports: [CommonModule, ReactiveFormsModule, RouterLink, PasswordInputComponent],
templateUrl: './login.component.html',
styleUrl: './login.component.scss'
})
@@ -32,14 +32,13 @@
<div class="champ">
<label for="motDePasse">Mot de passe</label>
<input
<app-password-input
id="motDePasse"
type="password"
formControlName="motDePasse"
autocomplete="new-password"
[attr.aria-invalid]="form.get('motDePasse')?.invalid && form.get('motDePasse')?.touched"
aria-describedby="motDePasse-erreur"
/>
></app-password-input>
@if (form.get('motDePasse')?.touched && form.get('motDePasse')?.invalid) {
<p id="motDePasse-erreur" class="erreur" role="alert">
@if (form.get('motDePasse')?.hasError('required')) {
@@ -53,9 +52,8 @@
<div class="champ">
<label for="confirmationMotDePasse">Confirmation du mot de passe</label>
<input
<app-password-input
id="confirmationMotDePasse"
type="password"
formControlName="confirmationMotDePasse"
autocomplete="new-password"
[attr.aria-invalid]="
@@ -63,7 +61,7 @@
form.get('confirmationMotDePasse')?.touched
"
aria-describedby="confirmation-erreur"
/>
></app-password-input>
@if (form.get('confirmationMotDePasse')?.touched) {
@if (form.get('confirmationMotDePasse')?.hasError('required')) {
<p id="confirmation-erreur" class="erreur" role="alert">
@@ -66,8 +66,8 @@ h1 {
font-size: 0.95rem;
&:hover:not(:disabled) {
background: var(--couleur-bleu-fonce);
border-color: var(--couleur-bleu-fonce);
background: var(--couleur-orange);
border-color: var(--couleur-orange);
}
}
@@ -12,6 +12,7 @@ import {
import { Router } from '@angular/router';
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 { RegisterDto } from '../../../core/models';
@@ -47,7 +48,7 @@ function motsDePasseEgauxValidator(groupe: AbstractControl): ValidationErrors |
@Component({
selector: 'app-register',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
imports: [CommonModule, ReactiveFormsModule, PasswordInputComponent],
templateUrl: './register.component.html',
styleUrl: './register.component.scss'
})
@@ -19,16 +19,15 @@
<div class="champ">
<label for="nouveauMotDePasse">Nouveau mot de passe</label>
<input
<app-password-input
id="nouveauMotDePasse"
type="password"
formControlName="nouveauMotDePasse"
autocomplete="new-password"
[attr.aria-invalid]="
form.get('nouveauMotDePasse')?.invalid && form.get('nouveauMotDePasse')?.touched
"
aria-describedby="nouveauMotDePasse-erreur"
/>
></app-password-input>
@if (form.get('nouveauMotDePasse')?.touched && form.get('nouveauMotDePasse')?.invalid) {
<p id="nouveauMotDePasse-erreur" class="erreur" role="alert">
@if (form.get('nouveauMotDePasse')?.hasError('required')) {
@@ -42,9 +41,8 @@
<div class="champ">
<label for="confirmation">Confirmer le mot de passe</label>
<input
<app-password-input
id="confirmation"
type="password"
formControlName="confirmation"
autocomplete="new-password"
[attr.aria-invalid]="
@@ -52,7 +50,7 @@
(form.get('confirmation')?.invalid || form.hasError('motsDePasseDifferents')))
"
aria-describedby="confirmation-erreur"
/>
></app-password-input>
@if (
form.get('confirmation')?.touched &&
(form.get('confirmation')?.invalid || form.hasError('motsDePasseDifferents'))
@@ -66,8 +66,8 @@ h1 {
font-size: 0.95rem;
&:hover:not(:disabled) {
background: var(--couleur-bleu-fonce);
border-color: var(--couleur-bleu-fonce);
background: var(--couleur-orange);
border-color: var(--couleur-orange);
}
}
@@ -12,6 +12,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
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 { ResetPasswordDto } from '../../../core/models';
@@ -43,7 +44,7 @@ function egaliteMotsDePasse(control: AbstractControl): ValidationErrors | null {
@Component({
selector: 'app-reset-password',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, RouterLink],
imports: [CommonModule, ReactiveFormsModule, RouterLink, PasswordInputComponent],
templateUrl: './reset-password.component.html',
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">
<h1>Profil</h1>
<h1>Compte utilisateur</h1>
<form [formGroup]="form" (ngSubmit)="enregistrer()" novalidate>
<div class="champ">
@@ -62,25 +62,67 @@
</div>
</form>
<!-- Maintenance : recalcul des distances routières via OpenRouteService. -->
<section class="maintenance">
<h2>Maintenance</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
<!-- Changement de mot de passe (utilisateur authentifié). -->
<section class="mot-de-passe">
<h2>Changer le mot de passe</h2>
<form [formGroup]="formMotDePasse" (ngSubmit)="changerMotDePasse()" novalidate>
@if (erreurMotDePasse()) {
<p class="erreur-globale" role="alert">{{ erreurMotDePasse() }}</p>
}
</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>
@@ -96,3 +96,23 @@ h1 {
opacity: 0.55;
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 { HttpErrorResponse } from '@angular/common/http';
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 { 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 { NotificationService } from '../../core/services/notification.service';
import { ProfilService } from '../../core/services/profil.service';
import { TrajetService } from '../../core/services/trajet.service';
import { BANFeature, Profil, SaveProfil } from '../../core/models';
import { BANFeature, ChangePasswordDto, 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
* - matricule (max 8 car., limite de saisie stricte via `maxlength`)
* - 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é
* si le géocodage de l'adresse a échoué (profil.domicileGeocode = false alors
* 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({
selector: 'app-profil',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
imports: [CommonModule, ReactiveFormsModule, PasswordInputComponent],
templateUrl: './profil.component.html',
styleUrl: './profil.component.scss'
})
@@ -34,7 +62,7 @@ export class ProfilComponent implements OnInit, OnDestroy {
private readonly profilService = inject(ProfilService);
private readonly banService = inject(BANService);
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). */
readonly matriculeMaxLength = 8;
@@ -47,8 +75,14 @@ export class ProfilComponent implements OnInit, OnDestroy {
/** true pendant l'appel de sauvegarde (désactive le bouton). */
readonly enregistrement = signal(false);
/** true pendant le recalcul des distances (désactive le bouton dédié). */
readonly recalculEnCours = signal(false);
/** Formulaire de changement de mot de passe. */
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 destroy$ = new Subject<void>();
@@ -61,6 +95,15 @@ export class ProfilComponent implements OnInit, OnDestroy {
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).
this.domicileInput$
.pipe(
@@ -143,32 +186,35 @@ export class ProfilComponent implements OnInit, OnDestroy {
});
}
/**
* Recalcule la distance routière de tous les trajets via ORS (action de maintenance).
* 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()) {
/** Change le mot de passe via `AuthService.changePassword`. */
changerMotDePasse(): void {
if (this.formMotDePasse.invalid || this.changementMotDePasseEnCours()) {
this.formMotDePasse.markAllAsTouched();
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).`
);
}
const valeur = this.formMotDePasse.getRawValue();
const dto: ChangePasswordDto = {
motDePasseActuel: valeur.motDePasseActuel ?? '',
nouveauMotDePasse: valeur.nouveauMotDePasse ?? ''
};
this.erreurMotDePasse.set(null);
this.changementMotDePasseEnCours.set(true);
this.authService.changePassword(dto).subscribe({
next: () => {
this.changementMotDePasseEnCours.set(false);
this.formMotDePasse.reset();
this.notification.success('Mot de passe modifié.');
},
error: () => {
this.recalculEnCours.set(false);
this.notification.error('Le recalcul des distances a échoué.');
error: (err: HttpErrorResponse) => {
this.changementMotDePasseEnCours.set(false);
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);
}
}