Ajout de l'authentification (compte unique, JWT, reset par email)
Application mono-utilisateur : inscription unique, connexion par JWT (12 h), protection de toutes les routes de données, réinitialisation de mot de passe par email avec repli de journalisation, onboarding réorganisé en deux étapes (inscription → connexion → profil + véhicule). Backend (SchotzIadKmTracker.Api) : - Entités Compte (singleton) et ResetPasswordToken + migration AjoutAuthentification. - Hachage PasswordHasher, JwtService (HS256, durée configurable), IEmailService (MailKit + repli LoggingEmailService selon Smtp:Host). - AuthService (register 409 si compte existant, login 401 générique, forgot-password non-divulgation, reset à usage unique + expiration), AuthController (5 endpoints publics), DTOs + validateurs FluentValidation. - Sécurité pipeline : JwtBearer + filtre d'autorisation global, UseAuthentication avant UseAuthorization ; /health et /api/auth/* publics. Frontend (schotziad-km-tracker, zoneless + signals) : - AuthService (localStorage), authInterceptor (Bearer + purge/redirection 401), authGuard, écrans Login/Register/ForgotPassword/ResetPassword, routes publiques et protection [authGuard, profilConfigureGuard]. Configuration : .env.example et docker-compose.yml (JWT__SecretKey, durées, Smtp__*). Tests : 54 tests backend (propriétés FsCheck P1–P14, exemples, intégration WebApplicationFactory) + tests frontend (AuthService, authGuard, authInterceptor, ResetPasswordComponent). Suite complète au vert. Spec : .kiro/specs/authentification (requirements, design, tasks).
This commit is contained in:
Binary file not shown.
@@ -3,6 +3,7 @@ import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||
import { timeoutInterceptor } from './core/interceptors/timeout.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
@@ -13,7 +14,11 @@ export const appConfig: ApplicationConfig = {
|
||||
// qui empêchait la détection de changement de se déclencher sur les réponses HTTP.
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter(routes),
|
||||
// TimeoutInterceptor : timeout 10s + gestion des erreurs réseau (Exigence 1.9).
|
||||
provideHttpClient(withInterceptors([timeoutInterceptor]))
|
||||
// Intercepteurs HTTP (ordre significatif) :
|
||||
// - authInterceptor : injecte le Bearer JWT et gère les 401 (Exigence 4.7) ;
|
||||
// - timeoutInterceptor : timeout 10s + gestion des erreurs réseau (Exigence 1.9).
|
||||
// authInterceptor est placé AVANT timeoutInterceptor pour injecter le Bearer
|
||||
// avant le contrôle de timeout.
|
||||
provideHttpClient(withInterceptors([authInterceptor, timeoutInterceptor]))
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
import { profilConfigureGuard } from './core/guards/profil-configure.guard';
|
||||
import { ForgotPasswordComponent } from './features/auth/forgot-password/forgot-password.component';
|
||||
import { LoginComponent } from './features/auth/login/login.component';
|
||||
import { RegisterComponent } from './features/auth/register/register.component';
|
||||
import { ResetPasswordComponent } from './features/auth/reset-password/reset-password.component';
|
||||
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';
|
||||
@@ -14,33 +19,42 @@ import { VehiculesComponent } from './features/vehicules/vehicules.component';
|
||||
/**
|
||||
* Routes de l'application (équivalent standalone de `app-routing.module.ts`).
|
||||
*
|
||||
* Placeholders pour le socle :
|
||||
* - `/consultation` : vue principale de consultation des trajets (tâche 16.4).
|
||||
* - `/onboarding` : parcours de première utilisation (tâche 21.1), accessible sans garde.
|
||||
* Organisation de l'accès :
|
||||
* - Routes PUBLIQUES (sans garde) : `login`, `register`, `forgot-password`,
|
||||
* `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`.
|
||||
* `authGuard` est vérifiée en premier ; on n'y applique pas `profilConfigureGuard`
|
||||
* pour ne pas piéger un utilisateur partiellement configuré.
|
||||
* - Routes de données PROTEGEES par `[authGuard, profilConfigureGuard]` :
|
||||
* `consultation`, `carnet-adresses`, `resume-mensuel`, `resume-annuel`, `bareme`,
|
||||
* `montants-regles`. L'authentification est contrôlée d'abord, puis la
|
||||
* configuration du profil (redirection vers `/onboarding` si `estConfigure = false`).
|
||||
*
|
||||
* La garde `profilConfigureGuard` (tâche 21.2) protège l'accès à la saisie /
|
||||
* consultation des trajets et aux résumés tant que le profil n'est pas configuré.
|
||||
*
|
||||
* Décision de routage :
|
||||
* - Routes protégées par la garde (bloquées si `estConfigure = false`, redirection
|
||||
* vers `/onboarding`) : `consultation`, `carnet-adresses`, `resume-mensuel`,
|
||||
* `resume-annuel`, `bareme`, `montants-regles`.
|
||||
* - Routes laissées accessibles SANS garde pour permettre à l'utilisateur de terminer
|
||||
* sa configuration : `onboarding` (toujours atteignable), `profil` et `vehicules`.
|
||||
* Appliquer la garde à `/profil` / `/vehicules` risquerait de piéger un utilisateur
|
||||
* partiellement configuré ; la garde ne vise que la saisie/consultation des trajets
|
||||
* et les rapports (voir design « ProfilConfigureGuard »).
|
||||
* Redirections : `''` -> `consultation` et wildcard `**` -> `consultation`.
|
||||
*/
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'consultation', pathMatch: 'full' },
|
||||
{ path: 'consultation', component: ConsultationJourComponent, canActivate: [profilConfigureGuard] },
|
||||
{ path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [profilConfigureGuard] },
|
||||
{ path: 'profil', component: ProfilComponent },
|
||||
{ path: 'vehicules', component: VehiculesComponent },
|
||||
{ path: 'resume-mensuel', component: ResumeMensuelComponent, canActivate: [profilConfigureGuard] },
|
||||
{ path: 'resume-annuel', component: ResumeAnnuelComponent, canActivate: [profilConfigureGuard] },
|
||||
{ path: 'bareme', component: BaremeComponent, canActivate: [profilConfigureGuard] },
|
||||
{ path: 'montants-regles', component: MontantsReglesComponent, canActivate: [profilConfigureGuard] },
|
||||
{ path: 'onboarding', component: OnboardingComponent },
|
||||
|
||||
// Routes PUBLIQUES (sans garde)
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{ path: 'register', component: RegisterComponent },
|
||||
{ path: 'forgot-password', component: ForgotPasswordComponent },
|
||||
{ path: 'reset-password', component: ResetPasswordComponent },
|
||||
|
||||
// Routes PROTEGEES par authGuard seul (Etape 2 de l'onboarding + profil / véhicules)
|
||||
{ path: 'onboarding', component: OnboardingComponent, canActivate: [authGuard] },
|
||||
{ path: 'profil', component: ProfilComponent, canActivate: [authGuard] },
|
||||
{ path: 'vehicules', component: VehiculesComponent, canActivate: [authGuard] },
|
||||
|
||||
// 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' }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router, UrlTree } from '@angular/router';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
RouterStateSnapshot
|
||||
} from '@angular/router';
|
||||
|
||||
import { authGuard } from './auth.guard';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
/**
|
||||
* Tests unitaires de la garde `authGuard` (CanActivateFn).
|
||||
*
|
||||
* Tâche 10.4 — Exigences 4.6 (routes protégées), 9.3 (redirection onboarding/login).
|
||||
* La garde est exécutée via `TestBed.runInInjectionContext` pour disposer du
|
||||
* contexte d'injection (`inject(...)`).
|
||||
*/
|
||||
describe('authGuard', () => {
|
||||
let estAuthentifieValeur: boolean;
|
||||
let urlTreeFactice: UrlTree;
|
||||
let createUrlTreeSpy: jasmine.Spy;
|
||||
|
||||
// Paramètres de la CanActivateFn (non utilisés par la garde, mais requis).
|
||||
const route = {} as ActivatedRouteSnapshot;
|
||||
const state = { url: '/consultation' } as RouterStateSnapshot;
|
||||
|
||||
beforeEach(() => {
|
||||
urlTreeFactice = {} as UrlTree;
|
||||
|
||||
const authServiceMock: Partial<AuthService> = {
|
||||
estAuthentifie: (() => estAuthentifieValeur) as AuthService['estAuthentifie']
|
||||
};
|
||||
|
||||
createUrlTreeSpy = jasmine.createSpy('createUrlTree').and.returnValue(urlTreeFactice);
|
||||
const routerMock: Partial<Router> = {
|
||||
createUrlTree: createUrlTreeSpy as unknown as Router['createUrlTree']
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authServiceMock },
|
||||
{ provide: Router, useValue: routerMock }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('retourne true lorsque l utilisateur est authentifié', () => {
|
||||
estAuthentifieValeur = true;
|
||||
|
||||
const resultat = TestBed.runInInjectionContext(() => authGuard(route, state));
|
||||
|
||||
expect(resultat).toBeTrue();
|
||||
expect(createUrlTreeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retourne un UrlTree vers /login lorsque l utilisateur n est pas authentifié', () => {
|
||||
estAuthentifieValeur = false;
|
||||
|
||||
const resultat = TestBed.runInInjectionContext(() => authGuard(route, state));
|
||||
|
||||
expect(createUrlTreeSpy).toHaveBeenCalledWith(['/login']);
|
||||
expect(resultat).toBe(urlTreeFactice);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router, UrlTree } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
/**
|
||||
* Garde de route `CanActivateFn` protégeant l'accès aux routes nécessitant une
|
||||
* authentification.
|
||||
*
|
||||
* Comportement (Exigences 4.6, 9.3) :
|
||||
* - Si `AuthService.estAuthentifie()` est vrai → navigation autorisée (retourne `true`).
|
||||
* - Sinon → redirection vers `/login` (retourne un `UrlTree`), ce qui bloque l'accès
|
||||
* à la route protégée tant que l'utilisateur n'est pas connecté. (Exigences 4.6, 9.3)
|
||||
*
|
||||
* Elle se compose avec la `profilConfigureGuard` existante : sur les routes de données,
|
||||
* on applique `[authGuard, profilConfigureGuard]` (l'authentification est vérifiée d'abord,
|
||||
* puis la configuration du profil).
|
||||
*/
|
||||
export const authGuard: CanActivateFn = (): boolean | UrlTree => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
return authService.estAuthentifie() ? true : router.createUrlTree(['/login']);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
HttpClient,
|
||||
provideHttpClient,
|
||||
withInterceptors
|
||||
} from '@angular/common/http';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting
|
||||
} from '@angular/common/http/testing';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { authInterceptor } from './auth.interceptor';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
/**
|
||||
* Tests unitaires de l'intercepteur `authInterceptor`.
|
||||
*
|
||||
* Tâche 10.4 — Exigence 4.7 :
|
||||
* - injection du Bearer sur les appels API lorsqu'un token est présent ;
|
||||
* - pas de Bearer sur les endpoints publics d'auth ;
|
||||
* - sur une réponse 401 : purge du token (AuthService.clearToken) + navigation /login.
|
||||
*/
|
||||
describe('authInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
let clearTokenSpy: jasmine.Spy;
|
||||
let navigateSpy: jasmine.Spy;
|
||||
let tokenCourant: string | null;
|
||||
|
||||
beforeEach(() => {
|
||||
tokenCourant = 'jwt-abc';
|
||||
clearTokenSpy = jasmine.createSpy('clearToken');
|
||||
navigateSpy = jasmine.createSpy('navigate');
|
||||
|
||||
const authServiceMock: Partial<AuthService> = {
|
||||
getToken: () => tokenCourant,
|
||||
clearToken: clearTokenSpy as unknown as AuthService['clearToken']
|
||||
};
|
||||
const routerMock: Partial<Router> = {
|
||||
navigate: navigateSpy as unknown as Router['navigate']
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: AuthService, useValue: authServiceMock },
|
||||
{ provide: Router, useValue: routerMock }
|
||||
]
|
||||
});
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('ajoute l en-tête Authorization Bearer sur un appel API lorsqu un token est présent', () => {
|
||||
const url = `${environment.apiUrl}/trajets`;
|
||||
http.get(url).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(url);
|
||||
expect(req.request.headers.get('Authorization')).toBe('Bearer jwt-abc');
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('n ajoute pas le Bearer sur un endpoint public d auth (/auth/login)', () => {
|
||||
const url = `${environment.apiUrl}/auth/login`;
|
||||
http.post(url, { email: 'a@b.fr', motDePasse: 'x' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(url);
|
||||
expect(req.request.headers.has('Authorization')).toBeFalse();
|
||||
req.flush({ token: 't', expiration: '2030-01-01T00:00:00Z' });
|
||||
});
|
||||
|
||||
it('n ajoute pas le Bearer lorsqu aucun token n est présent', () => {
|
||||
tokenCourant = null;
|
||||
const url = `${environment.apiUrl}/trajets`;
|
||||
http.get(url).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(url);
|
||||
expect(req.request.headers.has('Authorization')).toBeFalse();
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('purge le token et redirige vers /login sur une réponse 401', () => {
|
||||
const url = `${environment.apiUrl}/trajets`;
|
||||
http.get(url).subscribe({
|
||||
next: () => fail('la requête aurait dû échouer'),
|
||||
error: () => {
|
||||
/* l erreur est propagée : attendu */
|
||||
}
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(url);
|
||||
req.flush({ message: 'non autorisé' }, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
expect(clearTokenSpy).toHaveBeenCalled();
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
|
||||
it('ne purge pas le token sur une erreur non-401', () => {
|
||||
const url = `${environment.apiUrl}/trajets`;
|
||||
http.get(url).subscribe({
|
||||
next: () => fail('la requête aurait dû échouer'),
|
||||
error: () => {
|
||||
/* attendu */
|
||||
}
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(url);
|
||||
req.flush({ message: 'erreur serveur' }, { status: 500, statusText: 'Server Error' });
|
||||
|
||||
expect(clearTokenSpy).not.toHaveBeenCalled();
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
/**
|
||||
* Fragments de chemin correspondant aux endpoints publics d'authentification.
|
||||
* Aucune requête vers ces endpoints ne doit porter l'en-tête `Authorization`
|
||||
* (l'utilisateur n'est pas encore authentifié à ce stade).
|
||||
*/
|
||||
const ENDPOINTS_AUTH_PUBLICS = [
|
||||
'/auth/login',
|
||||
'/auth/register',
|
||||
'/auth/forgot-password',
|
||||
'/auth/reset-password',
|
||||
'/auth/compte-existe'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Intercepteur HTTP fonctionnel d'authentification (Exigence 4.7) :
|
||||
* - ajoute l'en-tête `Authorization: Bearer <token>` aux requêtes vers l'API
|
||||
* backend (`environment.apiUrl`) lorsqu'un token est présent, sauf pour les
|
||||
* endpoints publics d'auth et les appels externes (ex. BAN) ;
|
||||
* - sur une réponse en erreur 401, purge le token et redirige vers `/login`,
|
||||
* puis propage l'erreur.
|
||||
*
|
||||
* Il s'enregistre AVANT le `timeoutInterceptor` afin d'injecter le Bearer avant
|
||||
* le contrôle de timeout.
|
||||
*/
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
const cibleApi = req.url.startsWith(environment.apiUrl);
|
||||
const estEndpointPublic = ENDPOINTS_AUTH_PUBLICS.some((chemin) => req.url.includes(chemin));
|
||||
const token = authService.getToken();
|
||||
|
||||
// On n'ajoute le Bearer qu'aux appels vers notre API backend, hors endpoints
|
||||
// publics d'auth, et uniquement lorsqu'un token est disponible. Les appels
|
||||
// externes (BAN, etc.) restent inchangés.
|
||||
const requete =
|
||||
cibleApi && !estEndpointPublic && token
|
||||
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
|
||||
: req;
|
||||
|
||||
return next(requete).pipe(
|
||||
catchError((error: unknown) => {
|
||||
if (error instanceof HttpErrorResponse && error.status === 401) {
|
||||
// Jeton absent, expiré ou invalide : purge sans double navigation puis
|
||||
// redirection explicite vers l'écran de connexion.
|
||||
authService.clearToken();
|
||||
router.navigate(['/login']);
|
||||
}
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// Modèles Authentification — miroir de AuthDtos.cs (backend).
|
||||
|
||||
/** Identifiants de connexion — miroir de LoginDto. */
|
||||
export interface LoginDto {
|
||||
email: string;
|
||||
motDePasse: string;
|
||||
}
|
||||
|
||||
/** Données d'inscription — miroir de RegisterDto. */
|
||||
export interface RegisterDto {
|
||||
email: string;
|
||||
motDePasse: string;
|
||||
}
|
||||
|
||||
/** Résultat d'une connexion réussie — miroir de LoginResultDto. */
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
expiration: string;
|
||||
}
|
||||
|
||||
/** Réinitialisation du mot de passe — miroir de ResetPasswordDto. */
|
||||
export interface ResetPasswordDto {
|
||||
token: string;
|
||||
nouveauMotDePasse: string;
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export * from './vehicule.model';
|
||||
export * from './profil.model';
|
||||
export * from './bareme.model';
|
||||
export * from './adresse.model';
|
||||
export * from './auth.model';
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting
|
||||
} from '@angular/common/http/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginResult } from '../models';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
/**
|
||||
* Tests unitaires du service d'authentification.
|
||||
*
|
||||
* Tâche 10.4 — Exigences 2.3 (persistance du token / session), 3.1 (déconnexion).
|
||||
* Vérifie le stockage du JWT dans localStorage (clé `auth_token`), le reflet via
|
||||
* le signal `estAuthentifie()`/`getToken()`, et la purge à la déconnexion.
|
||||
*/
|
||||
describe('AuthService', () => {
|
||||
const CLE_TOKEN = 'auth_token';
|
||||
const baseUrl = `${environment.apiUrl}/auth`;
|
||||
|
||||
let service: AuthService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
// Environnement propre : aucun token résiduel avant construction du service.
|
||||
localStorage.clear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([])
|
||||
]
|
||||
});
|
||||
|
||||
service = TestBed.inject(AuthService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('démarre non authentifié lorsque localStorage est vide', () => {
|
||||
expect(service.getToken()).toBeNull();
|
||||
expect(service.estAuthentifie()).toBeFalse();
|
||||
});
|
||||
|
||||
it('login() envoie un POST vers {apiUrl}/auth/login', () => {
|
||||
service.login({ email: 'a@b.fr', motDePasse: 'motdepasse' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(`${baseUrl}/login`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({ email: 'a@b.fr', motDePasse: 'motdepasse' });
|
||||
|
||||
req.flush({ token: 'jwt-123', expiration: '2030-01-01T00:00:00Z' } as LoginResult);
|
||||
});
|
||||
|
||||
it('login() stocke le token dans localStorage et passe estAuthentifie() à true', () => {
|
||||
let resultat: LoginResult | undefined;
|
||||
service
|
||||
.login({ email: 'a@b.fr', motDePasse: 'motdepasse' })
|
||||
.subscribe((r) => (resultat = r));
|
||||
|
||||
const req = httpMock.expectOne(`${baseUrl}/login`);
|
||||
req.flush({ token: 'jwt-123', expiration: '2030-01-01T00:00:00Z' } as LoginResult);
|
||||
|
||||
expect(resultat?.token).toBe('jwt-123');
|
||||
expect(localStorage.getItem(CLE_TOKEN)).toBe('jwt-123');
|
||||
expect(service.getToken()).toBe('jwt-123');
|
||||
expect(service.estAuthentifie()).toBeTrue();
|
||||
});
|
||||
|
||||
it('logout() supprime le token de localStorage et repasse estAuthentifie() à false', () => {
|
||||
// On authentifie d'abord via login pour partir d'un état connecté réel.
|
||||
service.login({ email: 'a@b.fr', motDePasse: 'motdepasse' }).subscribe();
|
||||
httpMock
|
||||
.expectOne(`${baseUrl}/login`)
|
||||
.flush({ token: 'jwt-123', expiration: '2030-01-01T00:00:00Z' } as LoginResult);
|
||||
expect(service.estAuthentifie()).toBeTrue();
|
||||
|
||||
service.logout();
|
||||
|
||||
expect(localStorage.getItem(CLE_TOKEN)).toBeNull();
|
||||
expect(service.getToken()).toBeNull();
|
||||
expect(service.estAuthentifie()).toBeFalse();
|
||||
});
|
||||
|
||||
it('clearToken() purge le token sans naviguer', () => {
|
||||
service.login({ email: 'a@b.fr', motDePasse: 'motdepasse' }).subscribe();
|
||||
httpMock
|
||||
.expectOne(`${baseUrl}/login`)
|
||||
.flush({ token: 'jwt-123', expiration: '2030-01-01T00:00:00Z' } as LoginResult);
|
||||
expect(service.estAuthentifie()).toBeTrue();
|
||||
|
||||
service.clearToken();
|
||||
|
||||
expect(localStorage.getItem(CLE_TOKEN)).toBeNull();
|
||||
expect(service.getToken()).toBeNull();
|
||||
expect(service.estAuthentifie()).toBeFalse();
|
||||
});
|
||||
|
||||
it('getToken() reflète le contenu de localStorage à l initialisation', () => {
|
||||
// Simule un token déjà présent au démarrage puis reconstruit le service.
|
||||
localStorage.setItem(CLE_TOKEN, 'token-persistant');
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([])
|
||||
]
|
||||
});
|
||||
|
||||
const serviceReinit = TestBed.inject(AuthService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
|
||||
expect(serviceReinit.getToken()).toBe('token-persistant');
|
||||
expect(serviceReinit.estAuthentifie()).toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { LoginDto, LoginResult, RegisterDto, ResetPasswordDto } from '../models';
|
||||
|
||||
/** Clé de stockage du JWT dans localStorage. */
|
||||
const CLE_TOKEN = 'auth_token';
|
||||
|
||||
/**
|
||||
* Service d'authentification.
|
||||
*
|
||||
* Expose l'état d'authentification via des signals (contrainte zoneless).
|
||||
* Le JWT est conservé dans localStorage pour une session persistante, et le
|
||||
* signal `_token` reflète en permanence l'état courant.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly router = inject(Router);
|
||||
private readonly baseUrl = `${environment.apiUrl}/auth`;
|
||||
|
||||
/** Token courant (initialisé depuis localStorage au démarrage). */
|
||||
private readonly _token = signal<string | null>(this.lireTokenStocke());
|
||||
|
||||
/** Vrai lorsqu'un token est présent. */
|
||||
readonly estAuthentifie = computed(() => this._token() !== null);
|
||||
|
||||
/** Connexion (POST /auth/login) : stocke le token retourné sur succès. */
|
||||
login(dto: LoginDto): Observable<LoginResult> {
|
||||
return this.http
|
||||
.post<LoginResult>(`${this.baseUrl}/login`, dto)
|
||||
.pipe(tap((resultat) => this.enregistrerToken(resultat.token)));
|
||||
}
|
||||
|
||||
/** Inscription (POST /auth/register). */
|
||||
register(dto: RegisterDto): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/register`, dto);
|
||||
}
|
||||
|
||||
/** Demande de réinitialisation du mot de passe (POST /auth/forgot-password). */
|
||||
forgotPassword(email: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/forgot-password`, { email });
|
||||
}
|
||||
|
||||
/** Réinitialisation du mot de passe (POST /auth/reset-password). */
|
||||
resetPassword(dto: ResetPasswordDto): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/reset-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`);
|
||||
}
|
||||
|
||||
/** Retourne le token courant, ou null si non authentifié. */
|
||||
getToken(): string | null {
|
||||
return this._token();
|
||||
}
|
||||
|
||||
/** Déconnexion : purge le token et redirige vers l'écran de connexion. */
|
||||
logout(): void {
|
||||
this.supprimerToken();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge le token sans naviguer.
|
||||
* Utilisé par l'`authInterceptor` sur une réponse 401 : l'intercepteur gère
|
||||
* lui-même la redirection vers `/login` (évite une double navigation).
|
||||
*/
|
||||
clearToken(): void {
|
||||
this.supprimerToken();
|
||||
}
|
||||
|
||||
/** Lecture initiale du token depuis localStorage. */
|
||||
private lireTokenStocke(): string | null {
|
||||
return localStorage.getItem(CLE_TOKEN);
|
||||
}
|
||||
|
||||
/** Écriture du token dans localStorage et mise à jour du signal. */
|
||||
private enregistrerToken(token: string): void {
|
||||
localStorage.setItem(CLE_TOKEN, token);
|
||||
this._token.set(token);
|
||||
}
|
||||
|
||||
/** Suppression du token dans localStorage et mise à jour du signal. */
|
||||
private supprimerToken(): void {
|
||||
localStorage.removeItem(CLE_TOKEN);
|
||||
this._token.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<section class="forgot-password">
|
||||
<h1>Mot de passe oublié</h1>
|
||||
|
||||
@if (envoye()) {
|
||||
<p class="message-generique" role="status">{{ messageGenerique }}</p>
|
||||
|
||||
<nav class="liens" aria-label="Actions du compte">
|
||||
<a routerLink="/login">Retour à la connexion</a>
|
||||
</nav>
|
||||
} @else {
|
||||
<p class="intro">
|
||||
Saisissez l'adresse email associée à votre compte. Si elle correspond à un
|
||||
compte, un lien de réinitialisation vous sera envoyé.
|
||||
</p>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="demander()" novalidate>
|
||||
<div class="champ">
|
||||
<label for="email">Adresse email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
formControlName="email"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
[attr.aria-invalid]="form.get('email')?.invalid && form.get('email')?.touched"
|
||||
aria-describedby="email-erreur"
|
||||
/>
|
||||
@if (form.get('email')?.touched && form.get('email')?.invalid) {
|
||||
<p id="email-erreur" class="erreur" role="alert">
|
||||
@if (form.get('email')?.hasError('required')) {
|
||||
L'adresse email est obligatoire.
|
||||
} @else if (form.get('email')?.hasError('email')) {
|
||||
L'adresse email n'est pas valide.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-principal" [disabled]="soumissionEnCours()">
|
||||
{{ soumissionEnCours() ? 'Envoi…' : 'Envoyer le lien' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<nav class="liens" aria-label="Actions du compte">
|
||||
<a routerLink="/login">Retour à la connexion</a>
|
||||
</nav>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,86 @@
|
||||
.forgot-password {
|
||||
padding: 1.5rem;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin-bottom: 1rem;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.message-generique {
|
||||
color: #1b5e20;
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #a5d6a7;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.champ input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.champ input[aria-invalid='true'] {
|
||||
border-color: #c62828;
|
||||
}
|
||||
|
||||
.erreur {
|
||||
color: #c62828;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-principal {
|
||||
padding: 0.55rem 1.25rem;
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
border: 1px solid #1565c0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-principal:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.liens {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.liens a {
|
||||
color: #1565c0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.liens a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
|
||||
/** Message générique affiché quel que soit le résultat (Exigence 6.6). */
|
||||
const MESSAGE_GENERIQUE =
|
||||
'Si un compte correspond à cette adresse, un lien de réinitialisation a été envoyé.';
|
||||
|
||||
/**
|
||||
* Écran de demande de réinitialisation du mot de passe (`/forgot-password`).
|
||||
*
|
||||
* Formulaire réactif (email obligatoire et syntaxiquement valide). À la
|
||||
* soumission, `AuthService.forgotPassword` est appelé ; dans les DEUX cas
|
||||
* (succès comme erreur), le composant bascule vers un message GÉNÉRIQUE
|
||||
* identique et masque le formulaire, afin de ne jamais révéler si l'adresse
|
||||
* correspond à un compte existant (principe de non-divulgation, Exigence 6.6).
|
||||
*
|
||||
* Contrainte zoneless : tout état async est exposé via des signals
|
||||
* (`soumissionEnCours`, `envoye`).
|
||||
*
|
||||
* _Requirements: 6.6_
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-forgot-password',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
||||
templateUrl: './forgot-password.component.html',
|
||||
styleUrl: './forgot-password.component.scss'
|
||||
})
|
||||
export class ForgotPasswordComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
/** Message générique commun (succès et erreur) — utilisé par le template. */
|
||||
readonly messageGenerique = MESSAGE_GENERIQUE;
|
||||
|
||||
/** Vrai pendant l'appel (désactive le bouton). */
|
||||
readonly soumissionEnCours = signal(false);
|
||||
|
||||
/**
|
||||
* Bascule à `true` après l'appel, quel que soit le résultat. Masque le
|
||||
* formulaire et affiche le message générique (non-divulgation, Exigence 6.6).
|
||||
*/
|
||||
readonly envoye = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]]
|
||||
});
|
||||
}
|
||||
|
||||
/** Demande l'envoi d'un lien de réinitialisation pour l'email saisi. */
|
||||
demander(): void {
|
||||
if (this.form.invalid || this.soumissionEnCours()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const email = (this.form.getRawValue().email ?? '').trim();
|
||||
|
||||
this.soumissionEnCours.set(true);
|
||||
this.authService.forgotPassword(email).subscribe({
|
||||
// Succès comme erreur produisent exactement le même résultat visible.
|
||||
next: () => this.terminer(),
|
||||
error: () => this.terminer()
|
||||
});
|
||||
}
|
||||
|
||||
/** Finalise l'appel : affiche le message générique quel que soit le résultat. */
|
||||
private terminer(): void {
|
||||
this.soumissionEnCours.set(false);
|
||||
this.envoye.set(true);
|
||||
this.notification.info(this.messageGenerique);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<section class="login">
|
||||
<h1>Connexion</h1>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="seConnecter()" novalidate>
|
||||
@if (erreur()) {
|
||||
<p class="erreur-globale" role="alert">{{ erreur() }}</p>
|
||||
}
|
||||
|
||||
<div class="champ">
|
||||
<label for="email">Adresse email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
formControlName="email"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
[attr.aria-invalid]="form.get('email')?.invalid && form.get('email')?.touched"
|
||||
aria-describedby="email-erreur"
|
||||
/>
|
||||
@if (form.get('email')?.touched && form.get('email')?.invalid) {
|
||||
<p id="email-erreur" class="erreur" role="alert">
|
||||
@if (form.get('email')?.hasError('required')) {
|
||||
L'adresse email est obligatoire.
|
||||
} @else if (form.get('email')?.hasError('email')) {
|
||||
L'adresse email n'est pas valide.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label for="motDePasse">Mot de passe</label>
|
||||
<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"
|
||||
/>
|
||||
@if (form.get('motDePasse')?.touched && form.get('motDePasse')?.invalid) {
|
||||
<p id="motDePasse-erreur" class="erreur" role="alert">
|
||||
@if (form.get('motDePasse')?.hasError('required')) {
|
||||
Le mot de passe est obligatoire.
|
||||
} @else if (form.get('motDePasse')?.hasError('minlength')) {
|
||||
Le mot de passe doit comporter au moins 8 caractères.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-principal" [disabled]="soumissionEnCours()">
|
||||
{{ soumissionEnCours() ? 'Connexion…' : 'Se connecter' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<nav class="liens" aria-label="Actions du compte">
|
||||
<a routerLink="/forgot-password">Mot de passe oublié ?</a>
|
||||
@if (!compteExiste()) {
|
||||
<a routerLink="/register">Créer un compte</a>
|
||||
}
|
||||
</nav>
|
||||
</section>
|
||||
@@ -0,0 +1,81 @@
|
||||
.login {
|
||||
padding: 1.5rem;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.champ input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.champ input[aria-invalid='true'] {
|
||||
border-color: #c62828;
|
||||
}
|
||||
|
||||
.erreur {
|
||||
color: #c62828;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.erreur-globale {
|
||||
color: #c62828;
|
||||
background: #fdecea;
|
||||
border: 1px solid #f5c6c2;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-principal {
|
||||
padding: 0.55rem 1.25rem;
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
border: 1px solid #1565c0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-principal:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.liens {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.liens a {
|
||||
color: #1565c0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.liens a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { LoginDto } from '../../../core/models';
|
||||
|
||||
/**
|
||||
* Écran de connexion (`/login`) — Etape de session de l'authentification.
|
||||
*
|
||||
* Formulaire réactif (email + mot de passe). Sur succès, `AuthService.login`
|
||||
* stocke le JWT puis le composant navigue vers `/consultation` ; le routing et
|
||||
* la `profilConfigureGuard` redirigeront vers l'onboarding si le profil n'est
|
||||
* pas encore configuré. Sur 401, un message générique « Identifiants invalides. »
|
||||
* est affiché (Exigence 2.2).
|
||||
*
|
||||
* Contrainte zoneless : tout état async est exposé via des signals
|
||||
* (`soumissionEnCours`, `erreur`, `compteExiste`).
|
||||
*
|
||||
* _Requirements: 2.1, 2.3_
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
||||
templateUrl: './login.component.html',
|
||||
styleUrl: './login.component.scss'
|
||||
})
|
||||
export class LoginComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
/** Vrai pendant l'appel de connexion (désactive le bouton). */
|
||||
readonly soumissionEnCours = signal(false);
|
||||
|
||||
/** Message d'erreur courant, ou null si aucune erreur. */
|
||||
readonly erreur = signal<string | null>(null);
|
||||
|
||||
/**
|
||||
* Indique si un compte existe déjà. Le lien d'inscription n'est affiché que
|
||||
* lorsqu'aucun compte n'existe (application mono-utilisateur).
|
||||
*/
|
||||
readonly compteExiste = signal(true);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
motDePasse: ['', [Validators.required, Validators.minLength(8)]]
|
||||
});
|
||||
|
||||
this.authService.compteExiste().subscribe({
|
||||
next: (resultat) => this.compteExiste.set(resultat.existe),
|
||||
// En cas d'échec, on conserve la valeur par défaut (masque l'inscription).
|
||||
error: () => this.compteExiste.set(true)
|
||||
});
|
||||
}
|
||||
|
||||
/** Tente la connexion avec les identifiants saisis. */
|
||||
seConnecter(): void {
|
||||
if (this.form.invalid || this.soumissionEnCours()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const valeur = this.form.getRawValue();
|
||||
const dto: LoginDto = {
|
||||
email: (valeur.email ?? '').trim(),
|
||||
motDePasse: valeur.motDePasse ?? ''
|
||||
};
|
||||
|
||||
this.erreur.set(null);
|
||||
this.soumissionEnCours.set(true);
|
||||
this.authService.login(dto).subscribe({
|
||||
next: () => {
|
||||
this.soumissionEnCours.set(false);
|
||||
// La cible protégée oriente ensuite vers l'onboarding si nécessaire.
|
||||
this.router.navigate(['/consultation']);
|
||||
},
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.soumissionEnCours.set(false);
|
||||
if (err.status === 401) {
|
||||
const message = 'Identifiants invalides.';
|
||||
this.erreur.set(message);
|
||||
this.notification.error(message);
|
||||
} else {
|
||||
const message = 'La connexion a échoué. Veuillez réessayer.';
|
||||
this.erreur.set(message);
|
||||
this.notification.error(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<section class="login">
|
||||
<h1>Créer un compte</h1>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="sInscrire()" novalidate>
|
||||
@if (erreur()) {
|
||||
<p class="erreur-globale" role="alert">{{ erreur() }}</p>
|
||||
}
|
||||
|
||||
<div class="champ">
|
||||
<label for="email">Adresse email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
formControlName="email"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
[attr.aria-invalid]="form.get('email')?.invalid && form.get('email')?.touched"
|
||||
aria-describedby="email-erreur"
|
||||
/>
|
||||
@if (form.get('email')?.touched && form.get('email')?.invalid) {
|
||||
<p id="email-erreur" class="erreur" role="alert">
|
||||
@if (form.get('email')?.hasError('required')) {
|
||||
L'adresse email est obligatoire.
|
||||
} @else if (form.get('email')?.hasError('email')) {
|
||||
L'adresse email n'est pas valide.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label for="motDePasse">Mot de passe</label>
|
||||
<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"
|
||||
/>
|
||||
@if (form.get('motDePasse')?.touched && form.get('motDePasse')?.invalid) {
|
||||
<p id="motDePasse-erreur" class="erreur" role="alert">
|
||||
@if (form.get('motDePasse')?.hasError('required')) {
|
||||
Le mot de passe est obligatoire.
|
||||
} @else if (form.get('motDePasse')?.hasError('minlength')) {
|
||||
Le mot de passe doit comporter au moins 8 caractères.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label for="confirmationMotDePasse">Confirmation du mot de passe</label>
|
||||
<input
|
||||
id="confirmationMotDePasse"
|
||||
type="password"
|
||||
formControlName="confirmationMotDePasse"
|
||||
autocomplete="new-password"
|
||||
[attr.aria-invalid]="
|
||||
(form.get('confirmationMotDePasse')?.invalid || form.hasError('motsDePasseDifferents')) &&
|
||||
form.get('confirmationMotDePasse')?.touched
|
||||
"
|
||||
aria-describedby="confirmation-erreur"
|
||||
/>
|
||||
@if (form.get('confirmationMotDePasse')?.touched) {
|
||||
@if (form.get('confirmationMotDePasse')?.hasError('required')) {
|
||||
<p id="confirmation-erreur" class="erreur" role="alert">
|
||||
La confirmation du mot de passe est obligatoire.
|
||||
</p>
|
||||
} @else if (form.hasError('motsDePasseDifferents')) {
|
||||
<p id="confirmation-erreur" class="erreur" role="alert">
|
||||
Les mots de passe ne correspondent pas.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-principal" [disabled]="soumissionEnCours()">
|
||||
{{ soumissionEnCours() ? 'Création…' : 'Créer le compte' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<nav class="liens" aria-label="Actions du compte">
|
||||
<a routerLink="/login">Déjà un compte ? Se connecter</a>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -0,0 +1,81 @@
|
||||
.login {
|
||||
padding: 1.5rem;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.champ input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.champ input[aria-invalid='true'] {
|
||||
border-color: #c62828;
|
||||
}
|
||||
|
||||
.erreur {
|
||||
color: #c62828;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.erreur-globale {
|
||||
color: #c62828;
|
||||
background: #fdecea;
|
||||
border: 1px solid #f5c6c2;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-principal {
|
||||
padding: 0.55rem 1.25rem;
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
border: 1px solid #1565c0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-principal:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.liens {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.liens a {
|
||||
color: #1565c0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.liens a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { RegisterDto } from '../../../core/models';
|
||||
|
||||
/**
|
||||
* Validateur de groupe : vérifie l'égalité du mot de passe et de sa confirmation.
|
||||
*
|
||||
* Applique une erreur `motsDePasseDifferents` sur le groupe lorsque les deux
|
||||
* valeurs diffèrent. 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 motDePasse = groupe.get('motDePasse')?.value ?? '';
|
||||
const confirmation = groupe.get('confirmationMotDePasse')?.value ?? '';
|
||||
if (confirmation.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return motDePasse === confirmation ? null : { motsDePasseDifferents: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Écran d'inscription (`/register`) — Etape 1 de l'onboarding.
|
||||
*
|
||||
* Formulaire réactif (email + mot de passe + confirmation), avec un validateur
|
||||
* de groupe garantissant l'égalité des deux mots de passe. Sur succès, une
|
||||
* notification invite l'utilisateur à se connecter et le composant redirige
|
||||
* vers `/login` (Exigences 1.6, 9.2).
|
||||
*
|
||||
* Contrainte zoneless : tout état async est exposé via des signals
|
||||
* (`soumissionEnCours`, `erreur`).
|
||||
*
|
||||
* _Requirements: 1.6, 9.1, 9.2_
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
||||
templateUrl: './register.component.html',
|
||||
styleUrl: './register.component.scss'
|
||||
})
|
||||
export class RegisterComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
/** Vrai pendant l'appel d'inscription (désactive le bouton). */
|
||||
readonly soumissionEnCours = signal(false);
|
||||
|
||||
/** Message d'erreur courant, ou null si aucune erreur. */
|
||||
readonly erreur = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group(
|
||||
{
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
motDePasse: ['', [Validators.required, Validators.minLength(8)]],
|
||||
confirmationMotDePasse: ['', [Validators.required]]
|
||||
},
|
||||
{ validators: motsDePasseEgauxValidator }
|
||||
);
|
||||
}
|
||||
|
||||
/** Tente l'inscription avec les informations saisies. */
|
||||
sInscrire(): void {
|
||||
if (this.form.invalid || this.soumissionEnCours()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const valeur = this.form.getRawValue();
|
||||
const dto: RegisterDto = {
|
||||
email: (valeur.email ?? '').trim(),
|
||||
motDePasse: valeur.motDePasse ?? ''
|
||||
};
|
||||
|
||||
this.erreur.set(null);
|
||||
this.soumissionEnCours.set(true);
|
||||
this.authService.register(dto).subscribe({
|
||||
next: () => {
|
||||
this.soumissionEnCours.set(false);
|
||||
this.notification.success('Compte créé. Connectez-vous.');
|
||||
this.router.navigate(['/login']);
|
||||
},
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.soumissionEnCours.set(false);
|
||||
let message: string;
|
||||
if (err.status === 409) {
|
||||
message = 'Un compte existe déjà.';
|
||||
} else if (err.status === 400) {
|
||||
message = "Les informations saisies ne sont pas valides.";
|
||||
} else {
|
||||
message = "L'inscription a échoué. Veuillez réessayer.";
|
||||
}
|
||||
this.erreur.set(message);
|
||||
this.notification.error(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<section class="reset-password">
|
||||
<h1>Réinitialiser le mot de passe</h1>
|
||||
|
||||
@if (token() === null) {
|
||||
<p class="erreur-globale" role="alert">
|
||||
Le lien de réinitialisation est incomplet ou invalide.
|
||||
Veuillez redemander un lien de réinitialisation.
|
||||
</p>
|
||||
<nav class="liens" aria-label="Actions du compte">
|
||||
<a routerLink="/forgot-password">Redemander un lien</a>
|
||||
<a routerLink="/login">Retour à la connexion</a>
|
||||
</nav>
|
||||
} @else {
|
||||
<form [formGroup]="form" (ngSubmit)="reinitialiser()" novalidate>
|
||||
@if (erreur()) {
|
||||
<p class="erreur-globale" role="alert">{{ erreur() }}</p>
|
||||
}
|
||||
|
||||
<div class="champ">
|
||||
<label for="nouveauMotDePasse">Nouveau mot de passe</label>
|
||||
<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"
|
||||
/>
|
||||
@if (form.get('nouveauMotDePasse')?.touched && form.get('nouveauMotDePasse')?.invalid) {
|
||||
<p id="nouveauMotDePasse-erreur" class="erreur" role="alert">
|
||||
@if (form.get('nouveauMotDePasse')?.hasError('required')) {
|
||||
Le mot de passe est obligatoire.
|
||||
} @else if (form.get('nouveauMotDePasse')?.hasError('minlength')) {
|
||||
Le mot de passe doit comporter au moins 8 caractères.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label for="confirmation">Confirmer le mot de passe</label>
|
||||
<input
|
||||
id="confirmation"
|
||||
type="password"
|
||||
formControlName="confirmation"
|
||||
autocomplete="new-password"
|
||||
[attr.aria-invalid]="
|
||||
(form.get('confirmation')?.touched &&
|
||||
(form.get('confirmation')?.invalid || form.hasError('motsDePasseDifferents')))
|
||||
"
|
||||
aria-describedby="confirmation-erreur"
|
||||
/>
|
||||
@if (
|
||||
form.get('confirmation')?.touched &&
|
||||
(form.get('confirmation')?.invalid || form.hasError('motsDePasseDifferents'))
|
||||
) {
|
||||
<p id="confirmation-erreur" class="erreur" role="alert">
|
||||
@if (form.get('confirmation')?.hasError('required')) {
|
||||
La confirmation est obligatoire.
|
||||
} @else if (form.hasError('motsDePasseDifferents')) {
|
||||
Les mots de passe ne correspondent pas.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-principal" [disabled]="soumissionEnCours()">
|
||||
{{ soumissionEnCours() ? 'Réinitialisation…' : 'Réinitialiser' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<nav class="liens" aria-label="Actions du compte">
|
||||
<a routerLink="/login">Retour à la connexion</a>
|
||||
</nav>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,81 @@
|
||||
.reset-password {
|
||||
padding: 1.5rem;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.champ input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.champ input[aria-invalid='true'] {
|
||||
border-color: #c62828;
|
||||
}
|
||||
|
||||
.erreur {
|
||||
color: #c62828;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.erreur-globale {
|
||||
color: #c62828;
|
||||
background: #fdecea;
|
||||
border: 1px solid #f5c6c2;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-principal {
|
||||
padding: 0.55rem 1.25rem;
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
border: 1px solid #1565c0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-principal:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.liens {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.liens a {
|
||||
color: #1565c0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.liens a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ActivatedRoute, convertToParamMap, ParamMap } from '@angular/router';
|
||||
|
||||
import { ResetPasswordComponent } from './reset-password.component';
|
||||
|
||||
/**
|
||||
* Tests unitaires du composant de réinitialisation du mot de passe.
|
||||
*
|
||||
* Tâche 11.5 — Exigence 7.6 : le composant lit le paramètre de requête `token`
|
||||
* depuis l'URL (`ActivatedRoute.snapshot.queryParamMap`) et l'expose via le
|
||||
* signal `token()`. Absence de paramètre => `token()` vaut null.
|
||||
*
|
||||
* Contrainte zoneless : le signal est lu par appel (`component.token()`).
|
||||
*/
|
||||
describe('ResetPasswordComponent', () => {
|
||||
function configurer(queryParamMap: ParamMap): ResetPasswordComponent {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ResetPasswordComponent],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { queryParamMap } }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return TestBed.createComponent(ResetPasswordComponent).componentInstance;
|
||||
}
|
||||
|
||||
it('lit et expose le token présent dans les query params (?token=abc)', () => {
|
||||
const component = configurer(convertToParamMap({ token: 'abc' }));
|
||||
expect(component.token()).toBe('abc');
|
||||
});
|
||||
|
||||
it('expose token()===null lorsqu aucun token n est fourni', () => {
|
||||
const component = configurer(convertToParamMap({}));
|
||||
expect(component.token()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { ResetPasswordDto } from '../../../core/models';
|
||||
|
||||
/**
|
||||
* Validateur de groupe : vérifie l'égalité entre `nouveauMotDePasse` et
|
||||
* `confirmation`. Pose l'erreur `motsDePasseDifferents` sur le groupe lorsque
|
||||
* les deux valeurs diffèrent.
|
||||
*/
|
||||
function egaliteMotsDePasse(control: AbstractControl): ValidationErrors | null {
|
||||
const nouveau = control.get('nouveauMotDePasse')?.value ?? '';
|
||||
const confirmation = control.get('confirmation')?.value ?? '';
|
||||
return nouveau === confirmation ? null : { motsDePasseDifferents: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Écran de réinitialisation du mot de passe (`/reset-password`).
|
||||
*
|
||||
* Lit le paramètre de requête `token` depuis l'URL (via `ActivatedRoute`) et le
|
||||
* conserve dans un signal. Si le token est absent, un message invite à
|
||||
* redemander un lien. Le formulaire saisit le nouveau mot de passe (>= 8
|
||||
* caractères) et sa confirmation (validateur d'égalité). Sur succès, une
|
||||
* notification est émise puis le composant navigue vers `/login` (Exigence 7.6).
|
||||
*
|
||||
* Contrainte zoneless : tout état async est exposé via des signals
|
||||
* (`soumissionEnCours`, `erreur`, `token`).
|
||||
*
|
||||
* _Requirements: 7.6_
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-reset-password',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
||||
templateUrl: './reset-password.component.html',
|
||||
styleUrl: './reset-password.component.scss'
|
||||
})
|
||||
export class ResetPasswordComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
/** Token de réinitialisation lu depuis l'URL (`?token=...`), ou null si absent. */
|
||||
readonly token = signal<string | null>(
|
||||
this.route.snapshot.queryParamMap.get('token')
|
||||
);
|
||||
|
||||
/** Vrai pendant l'appel de réinitialisation (désactive le bouton). */
|
||||
readonly soumissionEnCours = signal(false);
|
||||
|
||||
/** Message d'erreur courant, ou null si aucune erreur. */
|
||||
readonly erreur = signal<string | null>(null);
|
||||
|
||||
readonly form: FormGroup = this.fb.group(
|
||||
{
|
||||
nouveauMotDePasse: ['', [Validators.required, Validators.minLength(8)]],
|
||||
confirmation: ['', [Validators.required]]
|
||||
},
|
||||
{ validators: egaliteMotsDePasse }
|
||||
);
|
||||
|
||||
/** Tente la réinitialisation avec le nouveau mot de passe saisi. */
|
||||
reinitialiser(): void {
|
||||
const token = this.token();
|
||||
if (this.form.invalid || token === null || this.soumissionEnCours()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const valeur = this.form.getRawValue();
|
||||
const dto: ResetPasswordDto = {
|
||||
token,
|
||||
nouveauMotDePasse: valeur.nouveauMotDePasse ?? ''
|
||||
};
|
||||
|
||||
this.erreur.set(null);
|
||||
this.soumissionEnCours.set(true);
|
||||
this.authService.resetPassword(dto).subscribe({
|
||||
next: () => {
|
||||
this.soumissionEnCours.set(false);
|
||||
this.notification.success('Votre mot de passe a été réinitialisé.');
|
||||
this.router.navigate(['/login']);
|
||||
},
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.soumissionEnCours.set(false);
|
||||
let message: string;
|
||||
if (err.status === 400) {
|
||||
// ProblemDetails : le backend fournit un message précis
|
||||
// (lien expiré / lien invalide) via `detail` le cas échéant.
|
||||
message =
|
||||
err.error?.detail ??
|
||||
'Le lien de réinitialisation est invalide ou a expiré.';
|
||||
} else {
|
||||
message = 'La réinitialisation a échoué. Veuillez réessayer.';
|
||||
}
|
||||
this.erreur.set(message);
|
||||
this.notification.error(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user