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:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user