feat(frontend): authentification frontend
ajout de la page login, changement de mot de passe forcé, rafraîchissement de session en mémoire, intercepteur, déconnexion, bouton logout sur le dashboard
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
<div class="auth-page">
|
||||
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<h1>Nouveau mot de passe</h1>
|
||||
<p class="auth-subtitle">Votre mot de passe est provisoire, vous devez le modifier avant de continuer</p>
|
||||
|
||||
<label for="current_password">Mot de passe actuel</label>
|
||||
<input
|
||||
id="current_password"
|
||||
type="password"
|
||||
formControlName="current_password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
<label for="new_password">Nouveau mot de passe</label>
|
||||
<input
|
||||
id="new_password"
|
||||
type="password"
|
||||
formControlName="new_password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<span class="auth-hint">12 à 128 caractères</span>
|
||||
|
||||
@if (errorMessage()) {
|
||||
<p class="auth-error">{{ errorMessage() }}</p>
|
||||
}
|
||||
|
||||
<button type="submit" [disabled]="form.invalid || isLoading()">
|
||||
{{ isLoading() ? 'Modification...' : 'Valider' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
: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;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { ChangePassword } from './change-password';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
|
||||
describe('ChangePassword', () => {
|
||||
let authMock: { changePassword: ReturnType<typeof vi.fn> };
|
||||
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(async () => {
|
||||
authMock = { changePassword: vi.fn() };
|
||||
routerMock = { navigate: vi.fn() };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ChangePassword, ReactiveFormsModule],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authMock },
|
||||
{ provide: Router, useValue: routerMock },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ current_password: 'old', new_password: 'trop-court' });
|
||||
|
||||
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' });
|
||||
|
||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||
|
||||
component.onSubmit();
|
||||
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
|
||||
|
||||
component.onSubmit();
|
||||
fixture.detectChanges(); // rend le bloc @if (errorMessage())
|
||||
|
||||
expect(component.errorMessage()).toContain('incorrect');
|
||||
const errorEl = fixture.nativeElement.querySelector('.auth-error');
|
||||
expect(errorEl?.textContent).toContain('incorrect');
|
||||
});
|
||||
|
||||
it('désactive le bouton tant que le formulaire est invalide', () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.nativeElement.querySelector('button[type="submit"]');
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull();
|
||||
});
|
||||
|
||||
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' });
|
||||
fixture.detectChanges();
|
||||
|
||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||
|
||||
const form = fixture.nativeElement.querySelector('form');
|
||||
form.dispatchEvent(new Event('submit'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(authMock.changePassword).toHaveBeenCalledWith({
|
||||
current_password: 'ancien-mot-de-passe',
|
||||
new_password: 'un-nouveau-mot-de-passe-valide',
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-change-password',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule],
|
||||
templateUrl: './change-password.html',
|
||||
styleUrl: './change-password.scss',
|
||||
})
|
||||
export class ChangePassword {
|
||||
private fb = inject(FormBuilder);
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
errorMessage = signal<string | null>(null);
|
||||
isLoading = signal(false);
|
||||
|
||||
form = this.fb.nonNullable.group({
|
||||
current_password: ['', Validators.required],
|
||||
new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]],
|
||||
});
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
this.isLoading.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
this.auth.changePassword(this.form.getRawValue()).subscribe({
|
||||
next: (response) => {
|
||||
this.router.navigate(['/dashboard']);
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading.set(false);
|
||||
this.errorMessage.set('Mot de passe actuel incorrect, ou nouveau mot de passe invalide (12 à 128 caractères).');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<div class="auth-page">
|
||||
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<h1>Connexion</h1>
|
||||
<p class="auth-subtitle">Accédez à votre espace EnerVision</p>
|
||||
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
formControlName="email"
|
||||
autocomplete="username"
|
||||
placeholder="vous@enervision.fr"
|
||||
/>
|
||||
|
||||
<label for="password">Mot de passe</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
formControlName="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
@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() ? 'Connexion...' : 'Se connecter' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,81 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
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-error {
|
||||
margin: 0.75rem 0 0;
|
||||
color: #dc2626;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { 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';
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('ne soumet pas si le formulaire est invalide', () => {
|
||||
const fixture = TestBed.createComponent(Login);
|
||||
fixture.componentInstance.onSubmit();
|
||||
expect(authMock.login).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirige vers /change-password si must_change_password est vrai', () => {
|
||||
const fixture = TestBed.createComponent(Login);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ email: 'a@a.com', password: 'secret' });
|
||||
|
||||
authMock.login.mockReturnValue(of({ principal: { role: 'admin', must_change_password: true } }));
|
||||
|
||||
component.onSubmit();
|
||||
expect(routerMock.navigate).toHaveBeenCalledWith(['/change-password']);
|
||||
});
|
||||
|
||||
it('redirige vers /dashboard si le mot de passe est déjà à jour', () => {
|
||||
const fixture = TestBed.createComponent(Login);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ email: 'a@a.com', password: 'secret' });
|
||||
|
||||
authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } }));
|
||||
|
||||
component.onSubmit();
|
||||
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
|
||||
});
|
||||
|
||||
it('affiche un message générique sur un 401', () => {
|
||||
const fixture = TestBed.createComponent(Login);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ email: 'a@a.com', password: 'wrong' });
|
||||
|
||||
authMock.login.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 401 })));
|
||||
|
||||
component.onSubmit();
|
||||
fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template
|
||||
|
||||
expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.');
|
||||
const errorEl = fixture.nativeElement.querySelector('.auth-error');
|
||||
expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.');
|
||||
});
|
||||
|
||||
it("affiche le délai d'attente sur un 429 avec Retry-After", () => {
|
||||
const fixture = TestBed.createComponent(Login);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ email: 'a@a.com', password: 'wrong' });
|
||||
|
||||
authMock.login.mockReturnValue(
|
||||
throwError(() => new HttpErrorResponse({ status: 429, headers: new HttpHeaders({ 'Retry-After': '30' }) }))
|
||||
);
|
||||
|
||||
component.onSubmit();
|
||||
fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds)
|
||||
|
||||
expect(component.retryAfterSeconds()).toBe(30);
|
||||
const errorEl = fixture.nativeElement.querySelector('.auth-error');
|
||||
expect(errorEl?.textContent).toContain('30s');
|
||||
});
|
||||
|
||||
it('désactive le bouton tant que le formulaire est invalide', () => {
|
||||
const fixture = TestBed.createComponent(Login);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.nativeElement.querySelector('button[type="submit"]');
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull();
|
||||
});
|
||||
|
||||
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
|
||||
const fixture = TestBed.createComponent(Login);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ email: 'a@a.com', password: 'secret' });
|
||||
fixture.detectChanges();
|
||||
|
||||
authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } }));
|
||||
|
||||
const form = fixture.nativeElement.querySelector('form');
|
||||
form.dispatchEvent(new Event('submit'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(authMock.login).toHaveBeenCalledWith({ email: 'a@a.com', password: 'secret' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule],
|
||||
templateUrl: './login.html',
|
||||
styleUrl: './login.scss',
|
||||
})
|
||||
export class Login {
|
||||
private fb = inject(FormBuilder);
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
errorMessage = signal<string | null>(null);
|
||||
retryAfterSeconds = signal<number | null>(null);
|
||||
isLoading = signal(false);
|
||||
|
||||
form = this.fb.nonNullable.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', Validators.required],
|
||||
});
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
|
||||
this.isLoading.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.retryAfterSeconds.set(null);
|
||||
|
||||
this.auth.login(this.form.getRawValue()).subscribe({
|
||||
next: (response) => {
|
||||
if (response.principal.must_change_password) {
|
||||
this.router.navigate(['/change-password']);
|
||||
return;
|
||||
}
|
||||
this.router.navigate(['/dashboard']);
|
||||
},
|
||||
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 tentatives, réessayez plus tard.');
|
||||
return;
|
||||
}
|
||||
this.errorMessage.set('Email ou mot de passe incorrect.');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
<header class="dashboard__header">
|
||||
<div>
|
||||
<h1>Vue d'ensemble</h1>
|
||||
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
|
||||
</div>
|
||||
<button type="button" class="logout-button" (click)="onLogout()">Déconnexion</button>
|
||||
</header>
|
||||
<div class="dashboard">
|
||||
<header class="dashboard__header">
|
||||
<h1>Vue d'ensemble</h1>
|
||||
|
||||
@@ -144,3 +144,30 @@ h2 {
|
||||
.alert-item__message {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.dashboard__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { of, throwError } from 'rxjs';
|
||||
import { Dashboard } from './dashboard';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { AlertsService } from '../../core/services/alerts.service';
|
||||
import {AuthService} from '../../core/services/auth.service';
|
||||
import {Router} from '@angular/router';
|
||||
|
||||
vi.mock('chart.js', () => {
|
||||
class ChartMock {
|
||||
@@ -92,4 +94,58 @@ describe('Dashboard', () => {
|
||||
|
||||
expect(fixture.componentInstance.alerts().length).toBe(0);
|
||||
});
|
||||
|
||||
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
|
||||
const routerMock = { navigate: vi.fn() };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: AuthService, useValue: authMock },
|
||||
{ provide: Router, useValue: routerMock },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.nativeElement.querySelector('.logout-button');
|
||||
button.click();
|
||||
|
||||
expect(authMock.logout).toHaveBeenCalled();
|
||||
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
const authMock = {
|
||||
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
||||
clearSession: vi.fn(),
|
||||
};
|
||||
const routerMock = { navigate: vi.fn() };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: AuthService, useValue: authMock },
|
||||
{ provide: Router, useValue: routerMock },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.nativeElement.querySelector('.logout-button');
|
||||
button.click();
|
||||
|
||||
expect(authMock.clearSession).toHaveBeenCalled();
|
||||
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,12 @@ import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
||||
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
|
||||
import { AlertsService } from '../../core/services/alerts.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { StatsSummary } from '../../shared/models/stats.model';
|
||||
import { Alert } from '../../shared/models/alert.model';
|
||||
|
||||
@@ -23,6 +25,8 @@ const UNAVAILABLE_MESSAGE =
|
||||
export class Dashboard implements OnInit {
|
||||
private statsService = inject(StatsService);
|
||||
private alertsService = inject(AlertsService);
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
stats = signal<StatsSummary | null>(null);
|
||||
@@ -50,6 +54,17 @@ export class Dashboard implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
onLogout(): void {
|
||||
this.auth.logout().subscribe({
|
||||
next: () => this.router.navigate(['/login']),
|
||||
error: () => {
|
||||
// Même si l'appel réseau échoue, on considère l'utilisateur déconnecté localement.
|
||||
this.auth.clearSession();
|
||||
this.router.navigate(['/login']);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private reportUnavailable(): Observable<never> {
|
||||
this.error.set(UNAVAILABLE_MESSAGE);
|
||||
return EMPTY;
|
||||
|
||||
Reference in New Issue
Block a user