Merge remote-tracking branch 'origin/dev' into feat/design-system
# Conflicts: # apps/frontend/src/app/features/auth/change-password/change-password.html # apps/frontend/src/app/features/auth/change-password/change-password.ts # apps/frontend/src/app/features/auth/login/login.html # apps/frontend/src/app/features/auth/login/login.ts
This commit is contained in:
@@ -5,6 +5,8 @@ export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
|
||||
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
|
||||
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) },
|
||||
{ path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) },
|
||||
{
|
||||
path: 'dashboard',
|
||||
canActivate: [authGuard],
|
||||
|
||||
@@ -41,7 +41,10 @@ describe('authInterceptor', () => {
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('ajoute le header Authorization quand un token est disponible', () => {
|
||||
http.get('/api/v1/stats/summary').subscribe();
|
||||
@@ -97,6 +100,19 @@ describe('authInterceptor', () => {
|
||||
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
|
||||
it("ne redirige pas vers /login sur un 401 de /auth/refresh si on est déjà sur /reset-password", () => {
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
pathname: '/reset-password',
|
||||
} as Location);
|
||||
|
||||
http.post('/api/v1/auth/refresh', {}).subscribe({ error: () => {} });
|
||||
const req = httpMock.expectOne('/api/v1/auth/refresh');
|
||||
req.flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
expect(authMock.clearSession).toHaveBeenCalled();
|
||||
expect(routerMock.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rafraîchit puis rejoue la requête sur un 401 avec error="expired"', () => {
|
||||
authMock.refreshShared.mockReturnValue(of({ access_token: 'new-token' }));
|
||||
authMock.getAccessToken.mockReturnValueOnce('old-token').mockReturnValue('new-token');
|
||||
|
||||
@@ -11,6 +11,16 @@ function parseAuthError(response: HttpErrorResponse): string | null {
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
const ROUTES_INVITEES = ['/login', '/forgot-password', '/reset-password'];
|
||||
|
||||
// Piège : le rafraîchissement de session lancé au démarrage de l'app (provideAppInitializer)
|
||||
// échoue silencieusement sans cookie valide. `window.location.pathname` (pas `router.url`,
|
||||
// pas encore fiable à ce stade) évite qu'un 401 de fond écrase la navigation vers le lien de
|
||||
// reset reçu par email.
|
||||
function surRouteInvitee(): boolean {
|
||||
return ROUTES_INVITEES.some((chemin) => window.location.pathname.startsWith(chemin));
|
||||
}
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
@@ -43,7 +53,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
|
||||
if (req.url.endsWith('/auth/refresh')) {
|
||||
auth.clearSession();
|
||||
router.navigate(['/login']);
|
||||
if (!surRouteInvitee()) {
|
||||
router.navigate(['/login']);
|
||||
}
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
@@ -51,7 +63,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
|
||||
if (kind === 'invalid_token') {
|
||||
auth.clearSession();
|
||||
router.navigate(['/login']);
|
||||
if (!surRouteInvitee()) {
|
||||
router.navigate(['/login']);
|
||||
}
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
@@ -65,7 +79,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
}),
|
||||
catchError((refreshError) => {
|
||||
auth.clearSession();
|
||||
router.navigate(['/login']);
|
||||
if (!surRouteInvitee()) {
|
||||
router.navigate(['/login']);
|
||||
}
|
||||
return throwError(() => refreshError);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -83,4 +83,17 @@ describe('AuthService', () => {
|
||||
|
||||
expect(result).toEqual(tokenResponse.principal);
|
||||
});
|
||||
|
||||
it('vérifie la validité du jeton de reset via GET /auth/reset-password/validate', () => {
|
||||
let result: { valid: boolean } | undefined;
|
||||
service.validateResetToken('un-secret-opaque').subscribe((r) => (result = r));
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
`${environment.apiUrl}/auth/reset-password/validate?token=un-secret-opaque`
|
||||
);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush({ valid: true });
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Service, signal, computed, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, tap, finalize, shareReplay } from 'rxjs';
|
||||
import { LoginRequest, PasswordChangeRequest, Principal, TokenResponse } from '../../shared/models/auth.model';
|
||||
import {
|
||||
ForgotPasswordRequest,
|
||||
LoginRequest,
|
||||
PasswordChangeRequest,
|
||||
Principal,
|
||||
ResetPasswordRequest,
|
||||
TokenResponse,
|
||||
} from '../../shared/models/auth.model';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
@Service()
|
||||
@@ -66,4 +73,20 @@ export class AuthService {
|
||||
me(): Observable<Principal> {
|
||||
return this.http.get<Principal>(`${environment.apiUrl}/auth/me`);
|
||||
}
|
||||
|
||||
forgotPassword(payload: ForgotPasswordRequest): Observable<void> {
|
||||
return this.http.post<void>(`${environment.apiUrl}/auth/forgot-password`, payload);
|
||||
}
|
||||
|
||||
resetPassword(payload: ResetPasswordRequest): Observable<TokenResponse> {
|
||||
return this.http
|
||||
.post<TokenResponse>(`${environment.apiUrl}/auth/reset-password`, payload, { withCredentials: true })
|
||||
.pipe(tap((response) => this.setSession(response)));
|
||||
}
|
||||
|
||||
validateResetToken(token: string): Observable<{ valid: boolean }> {
|
||||
return this.http.get<{ valid: boolean }>(`${environment.apiUrl}/auth/reset-password/validate`, {
|
||||
params: { token },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
formControlName="new_password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<span class="form-hint">12 à 128 caractères</span>
|
||||
<span class="form-hint">{{ passwordHint }}</span>
|
||||
|
||||
@if (errorMessage()) {
|
||||
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
|
||||
|
||||
@@ -32,10 +32,19 @@ describe('ChangePassword', () => {
|
||||
expect(authMock.changePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ne soumet pas si le mot de passe ne couvre pas les 4 classes de caractères', () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ current_password: 'old', new_password: 'longueur-suffisante-sans-majuscule-ni-chiffre' });
|
||||
|
||||
component.onSubmit();
|
||||
expect(authMock.changePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirige vers /dashboard après un changement réussi', () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' });
|
||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
||||
|
||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||
|
||||
@@ -46,7 +55,7 @@ describe('ChangePassword', () => {
|
||||
it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' });
|
||||
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
||||
|
||||
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
|
||||
|
||||
@@ -70,7 +79,7 @@ describe('ChangePassword', () => {
|
||||
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' });
|
||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
||||
fixture.detectChanges();
|
||||
|
||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||
@@ -81,7 +90,7 @@ describe('ChangePassword', () => {
|
||||
|
||||
expect(authMock.changePassword).toHaveBeenCalledWith({
|
||||
current_password: 'ancien-mot-de-passe',
|
||||
new_password: 'un-nouveau-mot-de-passe-valide',
|
||||
new_password: 'Un-nouveau-mot-de-passe1!',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '../../../shared/components/ui/button/button';
|
||||
import { Card } from '../../../shared/components/ui/card/card';
|
||||
import { Alert } from '../../../shared/components/ui/alert/alert';
|
||||
import { Brand } from '../../../shared/components/ui/brand/brand';
|
||||
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
|
||||
|
||||
@Component({
|
||||
selector: 'app-change-password',
|
||||
@@ -21,10 +22,11 @@ export class ChangePassword {
|
||||
|
||||
errorMessage = signal<string | null>(null);
|
||||
isLoading = signal(false);
|
||||
passwordHint = PASSWORD_HINT;
|
||||
|
||||
form = this.fb.nonNullable.group({
|
||||
current_password: ['', Validators.required],
|
||||
new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]],
|
||||
new_password: ['', passwordValidators],
|
||||
});
|
||||
|
||||
onSubmit(): void {
|
||||
@@ -38,7 +40,7 @@ export class ChangePassword {
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading.set(false);
|
||||
this.errorMessage.set('Mot de passe actuel incorrect, ou nouveau mot de passe invalide (12 à 128 caractères).');
|
||||
this.errorMessage.set(`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="auth-page">
|
||||
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<h1>Mot de passe oublié</h1>
|
||||
<p class="auth-subtitle">Recevez un lien de réinitialisation par email</p>
|
||||
|
||||
@if (submitted()) {
|
||||
<p class="auth-success">
|
||||
Si un compte existe pour cet email, un lien de réinitialisation vient d'être envoyé.
|
||||
Il expire dans 15 minutes.
|
||||
</p>
|
||||
} @else {
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
formControlName="email"
|
||||
autocomplete="username"
|
||||
placeholder="vous@enervision.fr"
|
||||
/>
|
||||
|
||||
@if (errorMessage()) {
|
||||
<p class="auth-error">
|
||||
{{ errorMessage() }}
|
||||
@if (retryAfterSeconds(); as seconds) {
|
||||
(réessayez dans {{ seconds }}s)
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
<button type="submit" [disabled]="form.invalid || isLoading()">
|
||||
{{ isLoading() ? 'Envoi...' : 'Envoyer le lien' }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<p class="auth-link"><a routerLink="/login">Retour à la connexion</a></p>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,104 @@
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: #f3f4f6;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
margin: 0.25rem 0 1.5rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0.35rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1.5rem;
|
||||
padding: 0.7rem;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
margin: 0.75rem 0 0;
|
||||
color: #dc2626;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.auth-success {
|
||||
margin: 0.75rem 0 0;
|
||||
color: #16a34a;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { ForgotPassword } from './forgot-password';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
|
||||
describe('ForgotPassword', () => {
|
||||
let authMock: { forgotPassword: ReturnType<typeof vi.fn> };
|
||||
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(async () => {
|
||||
authMock = { forgotPassword: vi.fn() };
|
||||
routerMock = { navigate: vi.fn() };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ForgotPassword, ReactiveFormsModule],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authMock },
|
||||
{ provide: Router, useValue: routerMock },
|
||||
{ provide: ActivatedRoute, useValue: {} },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('ne soumet pas si le formulaire est invalide', () => {
|
||||
const fixture = TestBed.createComponent(ForgotPassword);
|
||||
fixture.componentInstance.onSubmit();
|
||||
expect(authMock.forgotPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('affiche le message générique après une soumission réussie', () => {
|
||||
const fixture = TestBed.createComponent(ForgotPassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ email: 'operateur@enervision.fr' });
|
||||
authMock.forgotPassword.mockReturnValue(of(undefined));
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(component.submitted()).toBe(true);
|
||||
});
|
||||
|
||||
it('affiche le même message générique même quand le serveur répond une erreur autre que 429', () => {
|
||||
const fixture = TestBed.createComponent(ForgotPassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ email: 'inconnu@enervision.fr' });
|
||||
authMock.forgotPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 })));
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(component.submitted()).toBe(true);
|
||||
});
|
||||
|
||||
it('affiche le délai à respecter quand le taux limite est atteint', () => {
|
||||
const fixture = TestBed.createComponent(ForgotPassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ email: 'operateur@enervision.fr' });
|
||||
authMock.forgotPassword.mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 429,
|
||||
headers: new HttpHeaders({ 'Retry-After': '900' }),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(component.submitted()).toBe(false);
|
||||
expect(component.retryAfterSeconds()).toBe(900);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-forgot-password',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, RouterLink],
|
||||
templateUrl: './forgot-password.html',
|
||||
styleUrl: './forgot-password.scss',
|
||||
})
|
||||
export class ForgotPassword {
|
||||
private fb = inject(FormBuilder);
|
||||
private auth = inject(AuthService);
|
||||
|
||||
errorMessage = signal<string | null>(null);
|
||||
retryAfterSeconds = signal<number | null>(null);
|
||||
submitted = signal(false);
|
||||
isLoading = signal(false);
|
||||
|
||||
form = this.fb.nonNullable.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
});
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
|
||||
this.isLoading.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.retryAfterSeconds.set(null);
|
||||
|
||||
this.auth.forgotPassword(this.form.getRawValue()).subscribe({
|
||||
// Le message affiché ne dépend jamais du fait que le compte existe ou non : la réponse
|
||||
// du serveur est déjà générique, l'écran doit l'être aussi.
|
||||
next: () => {
|
||||
this.isLoading.set(false);
|
||||
this.submitted.set(true);
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
this.isLoading.set(false);
|
||||
if (error.status === 429) {
|
||||
const retryAfter = error.headers.get('Retry-After');
|
||||
this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null);
|
||||
this.errorMessage.set('Trop de demandes, réessayez plus tard.');
|
||||
return;
|
||||
}
|
||||
this.submitted.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@
|
||||
<ev-button type="submit" [disabled]="form.invalid || isLoading()">
|
||||
{{ isLoading() ? 'Connexion...' : 'Se connecter' }}
|
||||
</ev-button>
|
||||
|
||||
<p class="auth-link"><a routerLink="/forgot-password">Mot de passe oublié ?</a></p>
|
||||
</ev-card>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -36,3 +36,13 @@ ev-button {
|
||||
display: block;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,43 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { Login } from './login';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
|
||||
|
||||
function configure(queryParams: Record<string, string> = {}) {
|
||||
const authMock = { login: vi.fn() };
|
||||
const routerMock = { navigate: vi.fn() };
|
||||
|
||||
return {
|
||||
authMock,
|
||||
routerMock,
|
||||
testBed: TestBed.configureTestingModule({
|
||||
imports: [Login, ReactiveFormsModule],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authMock },
|
||||
{ provide: Router, useValue: routerMock },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { queryParamMap: convertToParamMap(queryParams) } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Login', () => {
|
||||
let authMock: { login: ReturnType<typeof vi.fn> };
|
||||
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(async () => {
|
||||
authMock = { login: vi.fn() };
|
||||
routerMock = { navigate: vi.fn() };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Login, ReactiveFormsModule],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authMock },
|
||||
{ provide: Router, useValue: routerMock },
|
||||
],
|
||||
}).compileComponents();
|
||||
const attirail = configure();
|
||||
authMock = attirail.authMock;
|
||||
routerMock = attirail.routerMock;
|
||||
await attirail.testBed.compileComponents();
|
||||
});
|
||||
|
||||
it('ne soumet pas si le formulaire est invalide', () => {
|
||||
@@ -84,6 +100,14 @@ describe('Login', () => {
|
||||
expect(errorEl?.textContent).toContain('30s');
|
||||
});
|
||||
|
||||
it('affiche le message standard quand on arrive avec ?motif=lien-expire', async () => {
|
||||
const attirail = configure({ motif: MOTIF_LIEN_RESET_INVALIDE });
|
||||
await attirail.testBed.compileComponents();
|
||||
const fixture = TestBed.createComponent(Login);
|
||||
|
||||
expect(fixture.componentInstance.errorMessage()).toContain('expiré');
|
||||
});
|
||||
|
||||
it('désactive le bouton tant que le formulaire est invalide', () => {
|
||||
const fixture = TestBed.createComponent(Login);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { Button } from '../../../shared/components/ui/button/button';
|
||||
import { Card } from '../../../shared/components/ui/card/card';
|
||||
import { Alert } from '../../../shared/components/ui/alert/alert';
|
||||
import { Brand } from '../../../shared/components/ui/brand/brand';
|
||||
import { MESSAGE_LIEN_RESET_INVALIDE, MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, Button, Card, Alert, Brand],
|
||||
imports: [ReactiveFormsModule, RouterLink, Button, Card, Alert, Brand],
|
||||
templateUrl: './login.html',
|
||||
styleUrl: './login.scss',
|
||||
})
|
||||
@@ -19,8 +20,13 @@ export class Login {
|
||||
private fb = inject(FormBuilder);
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
errorMessage = signal<string | null>(null);
|
||||
errorMessage = signal<string | null>(
|
||||
this.route.snapshot.queryParamMap.get('motif') === MOTIF_LIEN_RESET_INVALIDE
|
||||
? MESSAGE_LIEN_RESET_INVALIDE
|
||||
: null,
|
||||
);
|
||||
retryAfterSeconds = signal<number | null>(null);
|
||||
isLoading = signal(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<div class="auth-page">
|
||||
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<h1>Nouveau mot de passe</h1>
|
||||
|
||||
@if (hasToken && !isCheckingToken()) {
|
||||
<p class="auth-subtitle">Choisissez votre nouveau mot de passe</p>
|
||||
|
||||
<label for="new_password">Nouveau mot de passe</label>
|
||||
<input
|
||||
id="new_password"
|
||||
type="password"
|
||||
formControlName="new_password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<app-password-requirements [password]="password()" />
|
||||
|
||||
@if (errorMessage()) {
|
||||
<p class="auth-error">{{ errorMessage() }}</p>
|
||||
}
|
||||
|
||||
<button type="submit" [disabled]="form.invalid || isLoading()">
|
||||
{{ isLoading() ? 'Modification...' : 'Valider' }}
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (hasToken && isCheckingToken()) {
|
||||
<p class="auth-subtitle">Vérification du lien...</p>
|
||||
}
|
||||
|
||||
<p class="auth-link"><a routerLink="/forgot-password">Redemander un lien</a></p>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,104 @@
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: #f3f4f6;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
margin: 0.25rem 0 1.5rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0.35rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1.5rem;
|
||||
padding: 0.7rem;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
margin: 0.75rem 0 0;
|
||||
color: #dc2626;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.auth-success {
|
||||
margin: 0.75rem 0 0;
|
||||
color: #16a34a;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { ResetPassword } from './reset-password';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
|
||||
|
||||
function configure(token: string | null) {
|
||||
return TestBed.configureTestingModule({
|
||||
imports: [ResetPassword, ReactiveFormsModule],
|
||||
providers: [
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: {
|
||||
resetPassword: vi.fn(),
|
||||
validateResetToken: vi.fn().mockReturnValue(of({ valid: true })),
|
||||
},
|
||||
},
|
||||
{ provide: Router, useValue: { navigate: vi.fn() } },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { queryParamMap: convertToParamMap(token ? { token } : {}) } },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
}
|
||||
|
||||
describe('ResetPassword', () => {
|
||||
it("redirige vers /login avec le motif standard quand le jeton est absent de l'URL", async () => {
|
||||
await configure(null);
|
||||
const fixture = TestBed.createComponent(ResetPassword);
|
||||
const router = TestBed.inject(Router) as unknown as { navigate: ReturnType<typeof vi.fn> };
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.hasToken).toBe(false);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/login'], {
|
||||
queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE },
|
||||
});
|
||||
});
|
||||
|
||||
it('vérifie le jeton sans le consommer dès le chargement de la page', async () => {
|
||||
await configure('un-secret-opaque');
|
||||
const fixture = TestBed.createComponent(ResetPassword);
|
||||
const auth = TestBed.inject(AuthService) as unknown as { validateResetToken: ReturnType<typeof vi.fn> };
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(auth.validateResetToken).toHaveBeenCalledWith('un-secret-opaque');
|
||||
expect(fixture.componentInstance.isCheckingToken()).toBe(false);
|
||||
});
|
||||
|
||||
it('redirige immédiatement vers /login si la vérification signale un jeton invalide', async () => {
|
||||
await configure('un-secret-perime');
|
||||
TestBed.overrideProvider(AuthService, {
|
||||
useValue: { resetPassword: vi.fn(), validateResetToken: vi.fn().mockReturnValue(of({ valid: false })) },
|
||||
});
|
||||
const fixture = TestBed.createComponent(ResetPassword);
|
||||
const router = TestBed.inject(Router) as unknown as { navigate: ReturnType<typeof vi.fn> };
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/login'], {
|
||||
queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE },
|
||||
});
|
||||
});
|
||||
|
||||
it('ne soumet pas si le mot de passe ne respecte pas la politique de complexité', async () => {
|
||||
await configure('un-secret-opaque');
|
||||
const fixture = TestBed.createComponent(ResetPassword);
|
||||
const component = fixture.componentInstance;
|
||||
const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType<typeof vi.fn> };
|
||||
component.form.setValue({ new_password: 'trop-simple' });
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(auth.resetPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirige vers /dashboard après une réinitialisation réussie', async () => {
|
||||
await configure('un-secret-opaque');
|
||||
const fixture = TestBed.createComponent(ResetPassword);
|
||||
const component = fixture.componentInstance;
|
||||
const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType<typeof vi.fn> };
|
||||
const router = TestBed.inject(Router) as unknown as { navigate: ReturnType<typeof vi.fn> };
|
||||
component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' });
|
||||
auth.resetPassword.mockReturnValue(of({ principal: { role: 'operateur' } }));
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(auth.resetPassword).toHaveBeenCalledWith({
|
||||
token: 'un-secret-opaque',
|
||||
new_password: 'Un-nouveau-mot-de-passe1!',
|
||||
});
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/dashboard']);
|
||||
});
|
||||
|
||||
it('redirige vers /login avec le motif standard quand le lien est invalide ou expiré', async () => {
|
||||
await configure('un-secret-perime');
|
||||
const fixture = TestBed.createComponent(ResetPassword);
|
||||
const component = fixture.componentInstance;
|
||||
const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType<typeof vi.fn> };
|
||||
const router = TestBed.inject(Router) as unknown as { navigate: ReturnType<typeof vi.fn> };
|
||||
component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' });
|
||||
auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 })));
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/login'], {
|
||||
queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE },
|
||||
});
|
||||
});
|
||||
|
||||
it('affiche un message générique sur une erreur inattendue (pas 400)', async () => {
|
||||
await configure('un-secret-opaque');
|
||||
const fixture = TestBed.createComponent(ResetPassword);
|
||||
const component = fixture.componentInstance;
|
||||
const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType<typeof vi.fn> };
|
||||
component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' });
|
||||
auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 })));
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(component.errorMessage()).toContain('invalide');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ReactiveFormsModule, FormBuilder } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
|
||||
import { PasswordRequirementsChecklist } from '../../../shared/components/password-requirements/password-requirements';
|
||||
import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
|
||||
|
||||
@Component({
|
||||
selector: 'app-reset-password',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, RouterLink, PasswordRequirementsChecklist],
|
||||
templateUrl: './reset-password.html',
|
||||
styleUrl: './reset-password.scss',
|
||||
})
|
||||
export class ResetPassword implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
private token = this.route.snapshot.queryParamMap.get('token') ?? '';
|
||||
|
||||
errorMessage = signal<string | null>(null);
|
||||
isLoading = signal(false);
|
||||
passwordHint = PASSWORD_HINT;
|
||||
hasToken = this.token.length > 0;
|
||||
|
||||
form = this.fb.nonNullable.group({
|
||||
new_password: ['', passwordValidators],
|
||||
});
|
||||
|
||||
password = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' });
|
||||
isCheckingToken = signal(this.hasToken);
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!this.hasToken) {
|
||||
this.redirigeVersLoginLienInvalide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.auth.validateResetToken(this.token).subscribe({
|
||||
next: ({ valid }) => {
|
||||
this.isCheckingToken.set(false);
|
||||
if (!valid) {
|
||||
this.redirigeVersLoginLienInvalide();
|
||||
}
|
||||
},
|
||||
error: () => this.isCheckingToken.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid || !this.hasToken) return;
|
||||
|
||||
this.isLoading.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
this.auth.resetPassword({ token: this.token, new_password: this.form.getRawValue().new_password }).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/dashboard']);
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
this.isLoading.set(false);
|
||||
if (error.status === 400) {
|
||||
this.redirigeVersLoginLienInvalide();
|
||||
return;
|
||||
}
|
||||
this.errorMessage.set(`Nouveau mot de passe invalide (${this.passwordHint}).`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private redirigeVersLoginLienInvalide(): void {
|
||||
this.router.navigate(['/login'], { queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE } });
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<ul class="password-requirements">
|
||||
@for (requirement of requirements(); track requirement.label) {
|
||||
<li [class.met]="requirement.met" [class.unmet]="!requirement.met">
|
||||
<span class="password-requirements-icon">{{ requirement.met ? '✓' : '○' }}</span>
|
||||
{{ requirement.label }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.password-requirements {
|
||||
list-style: none;
|
||||
margin: 0.25rem 0 0;
|
||||
padding: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.password-requirements-icon {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.unmet {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.met {
|
||||
color: #16a34a;
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PasswordRequirementsChecklist } from './password-requirements';
|
||||
|
||||
describe('PasswordRequirementsChecklist', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PasswordRequirementsChecklist],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('ne coche aucune règle pour un mot de passe vide', () => {
|
||||
const fixture = TestBed.createComponent(PasswordRequirementsChecklist);
|
||||
fixture.componentRef.setInput('password', '');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.requirements().every((r) => !r.met)).toBe(true);
|
||||
});
|
||||
|
||||
it('ne coche que les règles satisfaites pour un mot de passe partiel', () => {
|
||||
const fixture = TestBed.createComponent(PasswordRequirementsChecklist);
|
||||
fixture.componentRef.setInput('password', 'abcdefgh');
|
||||
fixture.detectChanges();
|
||||
|
||||
const parLabel = new Map(fixture.componentInstance.requirements().map((r) => [r.label, r.met]));
|
||||
expect(parLabel.get('8 caractères minimum')).toBe(true);
|
||||
expect(parLabel.get('1 minuscule')).toBe(true);
|
||||
expect(parLabel.get('1 majuscule')).toBe(false);
|
||||
expect(parLabel.get('1 chiffre')).toBe(false);
|
||||
expect(parLabel.get('1 caractère spécial')).toBe(false);
|
||||
});
|
||||
|
||||
it('coche toutes les règles pour un mot de passe conforme', () => {
|
||||
const fixture = TestBed.createComponent(PasswordRequirementsChecklist);
|
||||
fixture.componentRef.setInput('password', 'Un-nouveau-mot-de-passe1!');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.requirements().every((r) => r.met)).toBe(true);
|
||||
});
|
||||
});
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { PASSWORD_REQUIREMENTS } from '../../validators/password.validator';
|
||||
|
||||
@Component({
|
||||
selector: 'app-password-requirements',
|
||||
standalone: true,
|
||||
templateUrl: './password-requirements.html',
|
||||
styleUrl: './password-requirements.scss',
|
||||
})
|
||||
export class PasswordRequirementsChecklist {
|
||||
password = input('');
|
||||
|
||||
requirements = computed(() =>
|
||||
PASSWORD_REQUIREMENTS.map((requirement) => ({
|
||||
label: requirement.label,
|
||||
met: requirement.test(this.password()),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const MOTIF_LIEN_RESET_INVALIDE = 'lien-expire';
|
||||
export const MESSAGE_LIEN_RESET_INVALIDE =
|
||||
'Ce lien de réinitialisation est invalide ou a expiré. Connectez-vous ou redemandez-en un.';
|
||||
@@ -10,6 +10,15 @@ export interface PasswordChangeRequest {
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordRequest {
|
||||
token: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
export interface Principal {
|
||||
id: string;
|
||||
email: string;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { passwordValidators } from './password.validator';
|
||||
|
||||
function estValide(motDePasse: string): boolean {
|
||||
return new FormControl(motDePasse, passwordValidators).valid;
|
||||
}
|
||||
|
||||
describe('passwordValidators', () => {
|
||||
it('accepte un mot de passe couvrant les quatre classes', () => {
|
||||
expect(estValide('Un-mot-de-passe1!')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepte un mot de passe accentué (alignement avec le backend, ex: "Sécurité1")', () => {
|
||||
expect(estValide('Sécurité1!')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuse un mot de passe sans majuscule même avec un "×" ou un "÷"', () => {
|
||||
expect(estValide('abcdefg1×')).toBe(false);
|
||||
expect(estValide('abcdefg1÷')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuse un mot de passe sans minuscule même avec un "×" ou un "÷"', () => {
|
||||
expect(estValide('ABCDEFG1×')).toBe(false);
|
||||
expect(estValide('ABCDEFG1÷')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// Contrainte : `PASSWORD_PATTERN` doit rester identique au validateur Pydantic de
|
||||
// `app/schemas/auth.py` côté backend (mêmes plages de majuscules/minuscules, excluant
|
||||
// × et ÷, mêmes chiffres 0-9, même jeu de caractères spéciaux). `\w`/`\d` divergent entre
|
||||
// JavaScript (ASCII) et Python (Unicode) : une négation aurait accepté ou rejeté un même
|
||||
// mot de passe différemment d'un côté à l'autre (ex. "Sécurité1").
|
||||
|
||||
import { Validators } from '@angular/forms';
|
||||
|
||||
export const PASSWORD_MIN_LENGTH = 8;
|
||||
export const PASSWORD_MAX_LENGTH = 128;
|
||||
export const PASSWORD_HINT =
|
||||
'8 à 128 caractères, avec au moins 1 majuscule, 1 minuscule, 1 chiffre et 1 caractère spécial';
|
||||
|
||||
const SPECIAL_CHARACTERS = '!@#$%^&*()\\-_=+[\\]{};:,.?';
|
||||
const PASSWORD_PATTERN = new RegExp(
|
||||
`^(?=.*[A-ZÀ-ÖØ-Þ])(?=.*[a-zà-öø-þ])` +
|
||||
`(?=.*[0-9])(?=.*[${SPECIAL_CHARACTERS}]).*$`,
|
||||
);
|
||||
|
||||
export const passwordValidators = [
|
||||
Validators.required,
|
||||
Validators.minLength(PASSWORD_MIN_LENGTH),
|
||||
Validators.maxLength(PASSWORD_MAX_LENGTH),
|
||||
Validators.pattern(PASSWORD_PATTERN),
|
||||
];
|
||||
|
||||
export interface PasswordRequirement {
|
||||
label: string;
|
||||
test: (value: string) => boolean;
|
||||
}
|
||||
|
||||
const SPECIAL_REGEX = new RegExp(`[${SPECIAL_CHARACTERS}]`);
|
||||
|
||||
export const PASSWORD_REQUIREMENTS: PasswordRequirement[] = [
|
||||
{ label: `${PASSWORD_MIN_LENGTH} caractères minimum`, test: (v) => v.length >= PASSWORD_MIN_LENGTH },
|
||||
{ label: '1 majuscule', test: (v) => /[A-ZÀ-ÖØ-Þ]/.test(v) },
|
||||
{ label: '1 minuscule', test: (v) => /[a-zà-öø-þ]/.test(v) },
|
||||
{ label: '1 chiffre', test: (v) => /[0-9]/.test(v) },
|
||||
{ label: '1 caractère spécial', test: (v) => SPECIAL_REGEX.test(v) },
|
||||
];
|
||||
Reference in New Issue
Block a user