Merge remote-tracking branch 'origin/dev' into feat/reconciliation-dag
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from functools import lru_cache
|
||||
from typing import Literal, Self
|
||||
|
||||
from pydantic import Field, SecretStr, model_validator
|
||||
from pydantic import Field, SecretStr, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
Environment = Literal["local", "dev", "staging", "prod"]
|
||||
@@ -76,6 +76,13 @@ class Settings(BaseSettings):
|
||||
expose_api_docs: bool | None = None
|
||||
metrics_token: SecretStr | None = None
|
||||
|
||||
# Compose passe `APP_METRICS_TOKEN` vide quand aucun jeton n'est posé : vide vaut absent, sinon
|
||||
# `/metrics` exigerait un `Bearer` sans valeur et plus rien ne pourrait le scruter.
|
||||
@field_validator("metrics_token", mode="before")
|
||||
@classmethod
|
||||
def _jeton_vide_vaut_absent(cls, valeur: object) -> object:
|
||||
return None if valeur == "" else valeur
|
||||
|
||||
@property
|
||||
def allowed_origins(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
|
||||
@@ -6,7 +6,8 @@ from fastapi import Depends, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from prometheus_fastapi_instrumentator import Instrumentator
|
||||
from prometheus_client import CollectorRegistry, GCCollector, PlatformCollector, ProcessCollector
|
||||
from prometheus_fastapi_instrumentator import Instrumentator, metrics
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
@@ -37,6 +38,16 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
await get_engine().dispose()
|
||||
|
||||
|
||||
# Pourquoi : le registre global n'accepte chaque métrique qu'une fois. Toute application créée
|
||||
# après la première, dans les tests notamment, n'aurait rien mesuré.
|
||||
def _registre_de_metriques() -> CollectorRegistry:
|
||||
registre = CollectorRegistry()
|
||||
ProcessCollector(registry=registre)
|
||||
PlatformCollector(registry=registre)
|
||||
GCCollector(registry=registre)
|
||||
return registre
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
resolved = settings or get_settings()
|
||||
configure_logging(resolved)
|
||||
@@ -102,7 +113,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
|
||||
register_error_handlers(application)
|
||||
|
||||
Instrumentator().instrument(application).expose(
|
||||
# Les sondes de santé tombent toutes les 30 s : comptées, elles fausseraient latences et débit.
|
||||
# Seaux fins autour du seuil de charge (p95 < 500 ms, ADR 0015), route par route.
|
||||
registre = _registre_de_metriques()
|
||||
Instrumentator(
|
||||
excluded_handlers=["/metrics", f"{resolved.api_prefix}/health/.*"], registry=registre
|
||||
).add(
|
||||
metrics.default(latency_lowr_buckets=(0.05, 0.1, 0.25, 0.5, 1, 2.5), registry=registre)
|
||||
).instrument(application).expose(
|
||||
application,
|
||||
endpoint="/metrics",
|
||||
include_in_schema=False,
|
||||
|
||||
@@ -72,6 +72,20 @@ async def test_metrics_stay_open_when_no_token_is_configured(client: AsyncClient
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_an_empty_metrics_token_means_no_token() -> None:
|
||||
assert (await interroge({"metrics_token": ""}, "/metrics")).status_code == 200
|
||||
|
||||
|
||||
async def test_metrics_ignore_health_probes_but_count_business_routes(client: AsyncClient) -> None:
|
||||
await client.get("/api/v1/health/live")
|
||||
await client.get("/api/v1/sites")
|
||||
|
||||
exposition = (await client.get("/metrics")).text
|
||||
|
||||
assert 'handler="/api/v1/health/live"' not in exposition
|
||||
assert 'handler="/api/v1/sites"' in exposition
|
||||
|
||||
|
||||
async def test_metrics_demand_the_token_once_one_is_configured() -> None:
|
||||
surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"}
|
||||
|
||||
|
||||
@@ -86,3 +86,9 @@ describe('MonComposant', () => {
|
||||
- Un fichier ou un dossier seulement :
|
||||
`npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts`
|
||||
(répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs)
|
||||
|
||||
## Au-delà des tests unitaires
|
||||
Les parcours utilisateur complets (connexion, rôles, sites, recommandations, alertes) sont
|
||||
testés de bout en bout par Playwright, contre l'API et le proxy réels : voir
|
||||
[tests/e2e/README.md](../../tests/e2e/README.md). Un élément sans rôle ni libellé stable que ces
|
||||
parcours doivent viser reçoit un `data-testid`.
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
sonar.projectKey=ProjetPiscine_EnerVision
|
||||
sonar.organization=groupe3-ener-vision
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# Dossier contenant le code source
|
||||
sonar.sources=apps/frontend/src,apps/backend/app
|
||||
sonar.tests=apps/backend/tests
|
||||
|
||||
# Liste des fichiers et dossiers à exclure de l'analyse
|
||||
# Liste des fichiers et dossiers à exclure de l'analyse
|
||||
sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json
|
||||
|
||||
# Chemin vers le rapport de couverture de code
|
||||
# Fichier généré par Vitest
|
||||
# Chemin vers le rapport de couverture de code
|
||||
# Fichier généré par Vitest
|
||||
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
||||
sonar.python.coverage.reportPaths=apps/backend/cov.info
|
||||
@@ -43,6 +43,31 @@ describe('AuthService', () => {
|
||||
expect(service.isAuthenticated()).toBe(true);
|
||||
});
|
||||
|
||||
it('garde le mot de passe provisoire pour un seul changement quand il doit être changé', () => {
|
||||
service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
|
||||
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
|
||||
...tokenResponse,
|
||||
principal: { ...tokenResponse.principal, must_change_password: true },
|
||||
});
|
||||
|
||||
expect(service.takeProvisionalPassword()).toBe('Provisoire');
|
||||
expect(service.takeProvisionalPassword()).toBeNull();
|
||||
});
|
||||
|
||||
it('ne garde aucun mot de passe quand il est déjà définitif, ni après la fin de session', () => {
|
||||
service.login({ email: 'a@a.com', password: 'Definitif' }).subscribe();
|
||||
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
|
||||
expect(service.takeProvisionalPassword()).toBeNull();
|
||||
|
||||
service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
|
||||
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
|
||||
...tokenResponse,
|
||||
principal: { ...tokenResponse.principal, must_change_password: true },
|
||||
});
|
||||
service.clearSession();
|
||||
expect(service.takeProvisionalPassword()).toBeNull();
|
||||
});
|
||||
|
||||
it('efface la session au logout', () => {
|
||||
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
|
||||
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
|
||||
|
||||
@@ -19,6 +19,9 @@ export class AuthService {
|
||||
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
|
||||
private accessTokenSignal = signal<string | null>(null);
|
||||
private principalSignal = signal<Principal | null>(null);
|
||||
// Pourquoi : redemander le mot de passe provisoire qu'on vient de vérifier laisse un gestionnaire
|
||||
// de mots de passe y coller un ancien mot de passe du site, et `/auth/password` répond 401.
|
||||
private provisionalPassword: string | null = null;
|
||||
|
||||
readonly principal = this.principalSignal.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
|
||||
@@ -37,12 +40,26 @@ export class AuthService {
|
||||
clearSession(): void {
|
||||
this.accessTokenSignal.set(null);
|
||||
this.principalSignal.set(null);
|
||||
this.provisionalPassword = null;
|
||||
}
|
||||
|
||||
login(credentials: LoginRequest): Observable<TokenResponse> {
|
||||
return this.http
|
||||
.post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
|
||||
.pipe(tap((response) => this.setSession(response)));
|
||||
.pipe(
|
||||
tap((response) => {
|
||||
this.setSession(response);
|
||||
this.provisionalPassword = response.principal.must_change_password
|
||||
? credentials.password
|
||||
: null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
takeProvisionalPassword(): string | null {
|
||||
const password = this.provisionalPassword;
|
||||
this.provisionalPassword = null;
|
||||
return password;
|
||||
}
|
||||
|
||||
// Un seul rafraîchissement en vol à la fois, partagé entre tous les
|
||||
|
||||
@@ -7,14 +7,18 @@
|
||||
Votre mot de passe est provisoire, vous devez le modifier avant de continuer
|
||||
</p>
|
||||
|
||||
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
||||
<input
|
||||
id="current_password"
|
||||
class="form-input"
|
||||
type="password"
|
||||
formControlName="current_password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<input hidden type="email" autocomplete="username" [value]="email" readonly />
|
||||
|
||||
@if (asksCurrentPassword()) {
|
||||
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
||||
<input
|
||||
id="current_password"
|
||||
class="form-input"
|
||||
type="password"
|
||||
formControlName="current_password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
}
|
||||
|
||||
<label class="form-label" for="new_password">Nouveau mot de passe</label>
|
||||
<input
|
||||
@@ -24,7 +28,7 @@
|
||||
formControlName="new_password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<span class="form-hint">{{ passwordHint }}</span>
|
||||
<app-password-requirements [password]="newPassword()" />
|
||||
|
||||
@if (errorMessage()) {
|
||||
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { signal } from '@angular/core';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { ChangePassword } from './change-password';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
|
||||
const NOUVEAU = 'Un-nouveau-mot-de-passe1!';
|
||||
|
||||
describe('ChangePassword', () => {
|
||||
let authMock: { changePassword: ReturnType<typeof vi.fn> };
|
||||
let authMock: {
|
||||
changePassword: ReturnType<typeof vi.fn>;
|
||||
takeProvisionalPassword: ReturnType<typeof vi.fn>;
|
||||
principal: ReturnType<typeof signal>;
|
||||
};
|
||||
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(async () => {
|
||||
authMock = { changePassword: vi.fn() };
|
||||
authMock = {
|
||||
changePassword: vi.fn(),
|
||||
takeProvisionalPassword: vi.fn().mockReturnValue(null),
|
||||
principal: signal({ email: 'johan@enervision.fr' }),
|
||||
};
|
||||
routerMock = { navigate: vi.fn() };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -23,6 +35,10 @@ describe('ChangePassword', () => {
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
function champActuel(fixture: { nativeElement: HTMLElement }): HTMLInputElement | null {
|
||||
return fixture.nativeElement.querySelector('#current_password');
|
||||
}
|
||||
|
||||
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
@@ -44,7 +60,7 @@ describe('ChangePassword', () => {
|
||||
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-passe1!' });
|
||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
|
||||
|
||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||
|
||||
@@ -52,46 +68,94 @@ describe('ChangePassword', () => {
|
||||
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-passe1!' });
|
||||
it("demande le mot de passe actuel quand la connexion ne l'a pas transmis (page rechargée)", () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
fixture.detectChanges();
|
||||
|
||||
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
|
||||
expect(champActuel(fixture)).not.toBeNull();
|
||||
});
|
||||
|
||||
component.onSubmit();
|
||||
fixture.detectChanges(); // rend le bloc @if (errorMessage())
|
||||
it('réutilise le mot de passe provisoire de la connexion sans le redemander', () => {
|
||||
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-24-caracteres');
|
||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.errorMessage()).toContain('incorrect');
|
||||
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
|
||||
expect(errorEl?.textContent).toContain('incorrect');
|
||||
expect(champActuel(fixture)).toBeNull();
|
||||
component.form.controls.new_password.setValue(NOUVEAU);
|
||||
component.onSubmit();
|
||||
|
||||
expect(authMock.changePassword).toHaveBeenCalledWith({
|
||||
current_password: 'Provisoire-24-caracteres',
|
||||
new_password: NOUVEAU,
|
||||
});
|
||||
});
|
||||
|
||||
it('associe le formulaire au compte connecté pour les gestionnaires de mots de passe', () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
fixture.detectChanges();
|
||||
|
||||
const identifiant = fixture.nativeElement.querySelector('input[autocomplete="username"]');
|
||||
expect(identifiant.value).toBe('johan@enervision.fr');
|
||||
});
|
||||
|
||||
it('sur un 401, dit que le mot de passe actuel est faux et le redemande', () => {
|
||||
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-perime');
|
||||
authMock.changePassword.mockReturnValue(
|
||||
throwError(() => new HttpErrorResponse({ status: 401 })),
|
||||
);
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.controls.new_password.setValue(NOUVEAU);
|
||||
|
||||
component.onSubmit();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.errorMessage()).toContain('Mot de passe actuel incorrect');
|
||||
expect(fixture.nativeElement.querySelector('.ev-alert')?.textContent).toContain('incorrect');
|
||||
expect(champActuel(fixture)).not.toBeNull();
|
||||
expect(component.form.controls.current_password.value).toBe('');
|
||||
});
|
||||
|
||||
it('sur un 422, dit que le nouveau mot de passe ne respecte pas la politique', () => {
|
||||
authMock.changePassword.mockReturnValue(
|
||||
throwError(() => new HttpErrorResponse({ status: 422 })),
|
||||
);
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(component.errorMessage()).toContain('Nouveau mot de passe refusé');
|
||||
expect(component.form.controls.current_password.value).toBe('ancien-mot-de-passe');
|
||||
});
|
||||
|
||||
it('désactive le bouton tant que le formulaire est invalide', () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
fixture.detectChanges();
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.nativeElement.querySelector('button[type="submit"]');
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
|
||||
const button = fixture.nativeElement.querySelector('button[type="submit"]');
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('.ev-alert')).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-passe1!' });
|
||||
fixture.detectChanges();
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
|
||||
fixture.detectChanges();
|
||||
|
||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||
|
||||
const form = fixture.nativeElement.querySelector('form');
|
||||
form.dispatchEvent(new Event('submit'));
|
||||
fixture.detectChanges();
|
||||
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-passe1!',
|
||||
expect(authMock.changePassword).toHaveBeenCalledWith({
|
||||
current_password: 'ancien-mot-de-passe',
|
||||
new_password: NOUVEAU,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
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';
|
||||
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 { PasswordRequirementsChecklist } from '../../../shared/components/password-requirements/password-requirements';
|
||||
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
|
||||
|
||||
@Component({
|
||||
selector: 'app-change-password',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, Button, Card, Alert, Brand],
|
||||
imports: [ReactiveFormsModule, Button, Card, Alert, Brand, PasswordRequirementsChecklist],
|
||||
templateUrl: './change-password.html',
|
||||
styleUrl: './change-password.scss',
|
||||
})
|
||||
@@ -20,30 +23,47 @@ export class ChangePassword {
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
private provisionalPassword = this.auth.takeProvisionalPassword();
|
||||
|
||||
errorMessage = signal<string | null>(null);
|
||||
isLoading = signal(false);
|
||||
passwordHint = PASSWORD_HINT;
|
||||
asksCurrentPassword = signal(this.provisionalPassword === null);
|
||||
email = this.auth.principal()?.email ?? '';
|
||||
|
||||
form = this.fb.nonNullable.group({
|
||||
current_password: ['', Validators.required],
|
||||
current_password: [this.provisionalPassword ?? '', Validators.required],
|
||||
new_password: ['', passwordValidators],
|
||||
});
|
||||
|
||||
newPassword = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' });
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
this.isLoading.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
this.auth.changePassword(this.form.getRawValue()).subscribe({
|
||||
next: (response) => {
|
||||
next: () => {
|
||||
this.router.navigate(['/dashboard']);
|
||||
},
|
||||
error: () => {
|
||||
error: (error: HttpErrorResponse) => {
|
||||
this.isLoading.set(false);
|
||||
this.errorMessage.set(
|
||||
`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`,
|
||||
);
|
||||
this.errorMessage.set(this.explique(error));
|
||||
if (error.status === 401) {
|
||||
this.form.controls.current_password.reset('');
|
||||
this.asksCurrentPassword.set(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private explique(error: HttpErrorResponse): string {
|
||||
if (error.status === 401) {
|
||||
return 'Mot de passe actuel incorrect : saisissez le mot de passe provisoire qui vous a été transmis.';
|
||||
}
|
||||
if (error.status === 422) {
|
||||
return `Nouveau mot de passe refusé (${PASSWORD_HINT}).`;
|
||||
}
|
||||
return 'Le changement de mot de passe a échoué, réessayez dans un instant.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
@if (data(); as d) {
|
||||
<div class="sites-grid">
|
||||
@for (site of d.sites; track site.site_id) {
|
||||
<ev-card class="site-card">
|
||||
<ev-card class="site-card" data-testid="site-card">
|
||||
<div class="site-card__header">
|
||||
<span class="site-card__name">{{ site.site_name }}</span>
|
||||
<ev-badge [tone]="badgeToneForOverall(site.overall)">{{ site.overall }}</ev-badge>
|
||||
|
||||
Reference in New Issue
Block a user