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