From 07ea8d21dc6ef7a01b141369da8d4a74b10c1e26 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 15:25:14 +0200 Subject: [PATCH 01/47] feat(backend): expose GET /api/v1/sites/{site_id}/current pour l'issue #29 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la dernière mesure d'un site (SiteService.current), en réutilisant la vérification d'existence déjà en place pour GET /sites/{site_id} : SiteService gagne une dépendance ReadingRepository, sur le modèle de composition déjà utilisé par StatsService/SensorService. Un site connu sans lecture rend 200 avec les champs de mesure à null et data_quality="critical" ; seul un site_id absent rend 404. --- apps/backend/app/api/deps.py | 2 +- apps/backend/app/api/v1/endpoints/sites.py | 20 +- apps/backend/app/repositories/reading.py | 9 + apps/backend/app/schemas/site.py | 20 ++ apps/backend/app/services/site.py | 65 +++++- apps/backend/openapi.json | 221 +++++++++++++++++++++ apps/backend/tests/api/test_openapi.py | 1 + apps/backend/tests/api/test_sites.py | 52 ++++- apps/backend/tests/services/test_site.py | 89 ++++++++- docs/architecture/20-backend.md | 7 +- 10 files changed, 474 insertions(+), 12 deletions(-) diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index aaf7403..eb78758 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -140,7 +140,7 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)] def get_site_service(session: SessionDep) -> SiteService: - return SiteService(sites=SiteRepository(session)) + return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session)) SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 984dd8b..93923e9 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status from app.api.deps import LecteurDep, SiteServiceDep from app.api.openapi import REPONSE_VALIDATION, Reponses from app.schemas.errors import ErrorResponse -from app.schemas.site import SiteResponse +from app.schemas.site import SiteCurrentResponse, SiteResponse from app.services.site import SiteNotFoundError router = APIRouter() @@ -34,3 +34,21 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" ) from erreur return SiteResponse.model_validate(site) + + +@router.get( + "/{site_id}/current", + response_model=SiteCurrentResponse, + summary="Dernière mesure d'un site", + responses=REPONSES_INTROUVABLE, +) +async def get_current( + site_id: str, _: LecteurDep, service: SiteServiceDep +) -> SiteCurrentResponse: + try: + actuel = await service.current(site_id) + except SiteNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" + ) from erreur + return SiteCurrentResponse.model_validate(actuel) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index 5424b46..c05ae60 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -19,3 +19,12 @@ class ReadingRepository: .order_by(Reading.site_id, Reading.timestamp.desc()) ) return (await self._session.execute(requete)).scalars().all() + + async def latest_for_site(self, site_id: str) -> Reading | None: + requete = ( + select(Reading) + .where(Reading.site_id == site_id) + .order_by(Reading.timestamp.desc()) + .limit(1) + ) + return await self._session.scalar(requete) diff --git a/apps/backend/app/schemas/site.py b/apps/backend/app/schemas/site.py index 82035f5..56a61b7 100644 --- a/apps/backend/app/schemas/site.py +++ b/apps/backend/app/schemas/site.py @@ -1,3 +1,6 @@ +from datetime import datetime +from typing import Literal + from pydantic import BaseModel, ConfigDict @@ -10,3 +13,20 @@ class SiteResponse(BaseModel): location: str | None capacity_kw: float | None status: str | None + + +class SiteCurrentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime | None + site_id: str + site_type: str + consumption_kw: float | None + consumption_kwh: float | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + null_reasons: list[str] + data_quality: Literal["good", "partial", "degraded", "critical"] diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py index 515497a..25a819d 100644 --- a/apps/backend/app/services/site.py +++ b/apps/backend/app/services/site.py @@ -1,8 +1,16 @@ from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Literal from app.models.energy import Site +from app.repositories.reading import ReadingRepository from app.repositories.site import SiteRepository +DataQuality = Literal["good", "partial", "degraded", "critical"] + +QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) + class SiteError(Exception): pass @@ -12,9 +20,26 @@ class SiteNotFoundError(SiteError): pass +@dataclass(frozen=True, slots=True) +class SiteCurrentReading: + timestamp: datetime | None + site_id: str + site_type: str + consumption_kw: float | None + consumption_kwh: float | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + null_reasons: list[str] + data_quality: DataQuality + + class SiteService: - def __init__(self, *, sites: SiteRepository) -> None: + def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None: self._sites = sites + self._readings = readings async def list_all(self) -> Sequence[Site]: return await self._sites.list_all() @@ -24,3 +49,41 @@ class SiteService: if site is None: raise SiteNotFoundError(site_id) return site + + async def current(self, site_id: str) -> SiteCurrentReading: + site = await self.get_by_id(site_id) + derniere = await self._readings.latest_for_site(site_id) + + if derniere is None: + return SiteCurrentReading( + timestamp=None, + site_id=site.site_id, + site_type=site.site_type, + consumption_kw=None, + consumption_kwh=None, + voltage_v=None, + current_a=None, + power_factor=None, + temperature_celsius=None, + humidity_percent=None, + null_reasons=[], + data_quality="critical", + ) + + qualite: DataQuality = ( + derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" + ) + return SiteCurrentReading( + timestamp=derniere.timestamp, + site_id=site.site_id, + site_type=site.site_type, + consumption_kw=derniere.consumption_kw, + consumption_kwh=derniere.consumption_kwh, + voltage_v=derniere.voltage_v, + current_a=derniere.current_a, + power_factor=derniere.power_factor, + temperature_celsius=derniere.temperature_celsius, + humidity_percent=derniere.humidity_percent, + null_reasons=derniere.null_reasons or [], + data_quality=qualite, + ) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index af962df..6844f72 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -921,6 +921,93 @@ } } }, + "/api/v1/sites/{site_id}/current": { + "get": { + "tags": [ + "sites" + ], + "summary": "Dernière mesure d'un site", + "operationId": "get_current_api_v1_sites__site_id__current_get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Site Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteCurrentResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "404": { + "description": "Aucun site ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/alerts": { "get": { "tags": [ @@ -1572,6 +1659,140 @@ ], "title": "Role" }, + "SiteCurrentResponse": { + "properties": { + "timestamp": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Timestamp" + }, + "site_id": { + "type": "string", + "title": "Site Id" + }, + "site_type": { + "type": "string", + "title": "Site Type" + }, + "consumption_kw": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kw" + }, + "consumption_kwh": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kwh" + }, + "voltage_v": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Voltage V" + }, + "current_a": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Current A" + }, + "power_factor": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Power Factor" + }, + "temperature_celsius": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Temperature Celsius" + }, + "humidity_percent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Humidity Percent" + }, + "null_reasons": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Null Reasons" + }, + "data_quality": { + "type": "string", + "enum": [ + "good", + "partial", + "degraded", + "critical" + ], + "title": "Data Quality" + } + }, + "type": "object", + "required": [ + "timestamp", + "site_id", + "site_type", + "consumption_kw", + "consumption_kwh", + "voltage_v", + "current_a", + "power_factor", + "temperature_celsius", + "humidity_percent", + "null_reasons", + "data_quality" + ], + "title": "SiteCurrentResponse" + }, "SiteResponse": { "properties": { "site_id": { diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index f7147da..3112937 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -31,6 +31,7 @@ ROUTES_A_ROLE = { ("POST", "/api/v1/users/{id}/password-reset"), ("GET", "/api/v1/sites"), ("GET", "/api/v1/sites/{site_id}"), + ("GET", "/api/v1/sites/{site_id}/current"), ("GET", "/api/v1/alerts"), ("GET", "/api/v1/recommendations"), ("GET", "/api/v1/recommendations/{recommendation_id}"), diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py index 3692565..dea8850 100644 --- a/apps/backend/tests/api/test_sites.py +++ b/apps/backend/tests/api/test_sites.py @@ -1,4 +1,5 @@ from collections.abc import Callable, Iterator +from datetime import UTC, datetime from uuid import uuid4 import pytest @@ -9,7 +10,9 @@ from app.api.deps import get_current_principal, get_site_service from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.models.energy import Site -from app.services.site import SiteNotFoundError +from app.services.site import SiteCurrentReading, SiteNotFoundError + +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) def principal(role: Role = Role.LECTEUR) -> Principal: @@ -33,10 +36,28 @@ def site(site_id: str = "site-1") -> Site: ) +def lecture_actuelle(site_id: str = "site-1") -> SiteCurrentReading: + return SiteCurrentReading( + timestamp=TIMESTAMP, + site_id=site_id, + site_type="industriel", + consumption_kw=87.34, + consumption_kwh=87.34, + voltage_v=401.2, + current_a=132.5, + power_factor=0.923, + temperature_celsius=22.1, + humidity_percent=58.4, + null_reasons=[], + data_quality="good", + ) + + class FauxService: def __init__(self, erreur: Exception | None = None) -> None: self._erreur = erreur self.site = site() + self.actuel = lecture_actuelle() async def list_all(self) -> list[Site]: return [self.site] @@ -46,6 +67,11 @@ class FauxService: raise self._erreur return self.site + async def current(self, site_id: str) -> SiteCurrentReading: + if self._erreur is not None: + raise self._erreur + return self.actuel + @pytest.fixture def lecteur_connecte(app: FastAPI) -> Iterator[None]: @@ -109,6 +135,30 @@ async def test_get_site_returns_404_for_an_unknown_site( assert response.status_code == 404 +async def test_get_current_returns_the_latest_reading( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sites/site-1/current") + + assert response.status_code == 200 + corps = response.json() + assert corps["site_id"] == "site-1" + assert corps["data_quality"] == "good" + assert corps["consumption_kw"] == 87.34 + + +async def test_get_current_returns_404_for_an_unknown_site( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(SiteNotFoundError("site-inconnu")) + + response = await client.get("/api/v1/sites/site-inconnu/current") + + assert response.status_code == 404 + + async def test_list_sites_reaches_the_repository_through_the_session( lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient ) -> None: diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py index 73ef21f..7e555e8 100644 --- a/apps/backend/tests/services/test_site.py +++ b/apps/backend/tests/services/test_site.py @@ -1,8 +1,13 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime + import pytest from app.models.energy import Site from app.services.site import SiteNotFoundError, SiteService +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + def site(site_id: str = "site-1") -> Site: return Site( @@ -15,6 +20,21 @@ def site(site_id: str = "site-1") -> Site: ) +@dataclass +class FauxLecture: + site_id: str + timestamp: datetime = TIMESTAMP + consumption_kw: float | None = 87.34 + consumption_kwh: float | None = 87.34 + voltage_v: float | None = 401.2 + current_a: float | None = 132.5 + power_factor: float | None = 0.923 + temperature_celsius: float | None = 22.1 + humidity_percent: float | None = 58.4 + null_reasons: list[str] | None = field(default_factory=list) + data_quality: str | None = "good" + + class FakeRepository: def __init__(self, sites: list[Site]) -> None: self._sites = sites @@ -26,24 +46,79 @@ class FakeRepository: return next((s for s in self._sites if s.site_id == site_id), None) -async def test_list_all_returns_the_repository_sites() -> None: - service = SiteService(sites=FakeRepository([site("a"), site("b")])) +class FauxDepotLectures: + def __init__(self, lectures: dict[str, FauxLecture]) -> None: + self._lectures = lectures - sites = await service.list_all() + async def latest_for_site(self, site_id: str) -> FauxLecture | None: + return self._lectures.get(site_id) + + +def service( + sites: list[Site], lectures: dict[str, FauxLecture] | None = None +) -> SiteService: + return SiteService( + sites=FakeRepository(sites), # type: ignore[arg-type] + readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type] + ) + + +async def test_list_all_returns_the_repository_sites() -> None: + svc = service([site("a"), site("b")]) + + sites = await svc.list_all() assert [s.site_id for s in sites] == ["a", "b"] async def test_get_by_id_returns_the_matching_site() -> None: - service = SiteService(sites=FakeRepository([site("a")])) + svc = service([site("a")]) - trouve = await service.get_by_id("a") + trouve = await svc.get_by_id("a") assert trouve.site_id == "a" async def test_get_by_id_raises_when_the_site_is_unknown() -> None: - service = SiteService(sites=FakeRepository([])) + svc = service([]) with pytest.raises(SiteNotFoundError): - await service.get_by_id("inconnu") + await svc.get_by_id("inconnu") + + +async def test_current_raises_when_the_site_is_unknown() -> None: + svc = service([]) + + with pytest.raises(SiteNotFoundError): + await svc.current("inconnu") + + +async def test_current_returns_every_field_as_null_when_the_site_has_no_reading() -> None: + svc = service([site("a")]) + + actuel = await svc.current("a") + + assert actuel.timestamp is None + assert actuel.consumption_kw is None + assert actuel.data_quality == "critical" + assert actuel.null_reasons == [] + + +async def test_current_copies_every_field_from_the_latest_reading() -> None: + svc = service([site("a")], {"a": FauxLecture(site_id="a")}) + + actuel = await svc.current("a") + + assert actuel.timestamp == TIMESTAMP + assert actuel.site_type == "industriel" + assert actuel.consumption_kw == 87.34 + assert actuel.voltage_v == 401.2 + assert actuel.data_quality == "good" + + +async def test_current_treats_an_unknown_data_quality_as_critical() -> None: + svc = service([site("a")], {"a": FauxLecture(site_id="a", data_quality=None)}) + + actuel = await svc.current("a") + + assert actuel.data_quality == "critical" diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index fec2794..32253fb 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -142,6 +142,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 | | GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 | | GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 | +| GET | `/api/v1/sites/{site_id}/current` | Dernière mesure d'un site. `lecteur` | 401, 403, 404, 422, 500 | | GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 | | GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 | | GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 | @@ -169,7 +170,11 @@ gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exposée. `GET /stats/summary` agrège deux repositories (`SiteRepository`, `ReadingRepository`) dans un service dédié plutôt que d'exposer une table : elle n'entre donc pas dans ce gabarit -route-par-table. Le contrat détaillé pour le frontend est dans +route-par-table. `GET /sites/{site_id}/current` reste sur le gabarit `sites`, mais +`SiteService` gagne la même seconde dépendance (`ReadingRepository`) pour restituer la +dernière `Reading` du site : un site connu sans lecture rend `200` avec tous les champs de +mesure à `null` et `data_quality="critical"`, seul un `site_id` absent de la base rend `404`. +Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` From 2f97e4d4344deb8831559e6cf8a0d74e3061911e Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 15:27:05 +0200 Subject: [PATCH 02/47] fix(backend): corrige formatage ruff et typage mypy sur sites/current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI en échec sur ruff format (ligne trop longue) et mypy (retour Any non annoté, assignation Literal non étroite). Corrige sans changer le comportement. --- apps/backend/app/api/v1/endpoints/sites.py | 4 +--- apps/backend/app/repositories/reading.py | 3 ++- apps/backend/app/services/site.py | 6 +++--- apps/backend/tests/services/test_site.py | 4 +--- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 93923e9..5687b33 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -42,9 +42,7 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site summary="Dernière mesure d'un site", responses=REPONSES_INTROUVABLE, ) -async def get_current( - site_id: str, _: LecteurDep, service: SiteServiceDep -) -> SiteCurrentResponse: +async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse: try: actuel = await service.current(site_id) except SiteNotFoundError as erreur: diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index c05ae60..7981b9e 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -27,4 +27,5 @@ class ReadingRepository: .order_by(Reading.timestamp.desc()) .limit(1) ) - return await self._session.scalar(requete) + lecture: Reading | None = await self._session.scalar(requete) + return lecture diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py index 25a819d..50d2e24 100644 --- a/apps/backend/app/services/site.py +++ b/apps/backend/app/services/site.py @@ -70,9 +70,9 @@ class SiteService: data_quality="critical", ) - qualite: DataQuality = ( - derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" - ) + qualite: DataQuality = "critical" + if derniere.data_quality in QUALITES_CONNUES: + qualite = derniere.data_quality # type: ignore[assignment] return SiteCurrentReading( timestamp=derniere.timestamp, site_id=site.site_id, diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py index 7e555e8..76584fb 100644 --- a/apps/backend/tests/services/test_site.py +++ b/apps/backend/tests/services/test_site.py @@ -54,9 +54,7 @@ class FauxDepotLectures: return self._lectures.get(site_id) -def service( - sites: list[Site], lectures: dict[str, FauxLecture] | None = None -) -> SiteService: +def service(sites: list[Site], lectures: dict[str, FauxLecture] | None = None) -> SiteService: return SiteService( sites=FakeRepository(sites), # type: ignore[arg-type] readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type] From 9161b74874a13b0b17df9eca9679c2120362e683 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 10:53:58 +0200 Subject: [PATCH 03/47] feat(auth): politique de complexite du mot de passe et flux de reinitialisation Remplace la regle de longueur seule (12 caracteres) par une exigence de composition (8 caracteres minimum, majuscule, minuscule, chiffre, caractere special), non documentee dans les exigences officielles du projet, par une regle explicite partagee entre le backend (validateur Pydantic) et le frontend. Ajoute un flux "mot de passe oublie" en libre-service, absent jusqu'ici : jeton a usage unique hache en base (meme principe que les refresh tokens), expirant a 15 minutes, envoye par email via un service SMTP (aiosmtplib, Mailpit en dev), avec limitation de debit dediee et reponse generique pour eviter l'enumeration des comptes. Closes #87 --- apps/backend/.env.example | 10 + apps/backend/README.md | 2 + ...c0adab96238c_jetons_de_reinitialisation.py | 96 ++++++++++ apps/backend/app/api/deps.py | 31 +++- apps/backend/app/api/openapi.py | 13 ++ apps/backend/app/api/v1/endpoints/auth.py | 83 +++++++++ apps/backend/app/cli.py | 31 +++- apps/backend/app/core/config.py | 13 ++ apps/backend/app/core/mailer.py | 48 +++++ apps/backend/app/models/__init__.py | 4 + apps/backend/app/models/audit_log.py | 2 + .../app/models/password_reset_attempt.py | 27 +++ .../app/models/password_reset_token.py | 40 ++++ .../repositories/password_reset_attempt.py | 42 +++++ .../app/repositories/password_reset_token.py | 68 +++++++ apps/backend/app/schemas/auth.py | 45 ++++- apps/backend/app/services/auth.py | 110 +++++++++++ apps/backend/openapi.json | 175 +++++++++++++++++- apps/backend/pyproject.toml | 1 + apps/backend/tests/api/test_auth.py | 100 ++++++++++ .../tests/api/test_route_protection.py | 4 + .../repositories/test_password_reset_token.py | 114 ++++++++++++ apps/backend/tests/schemas/__init__.py | 0 apps/backend/tests/schemas/test_auth.py | 41 ++++ apps/backend/tests/services/test_auth.py | 158 +++++++++++++++- apps/backend/tests/test_cli.py | 19 +- apps/backend/uv.lock | 11 ++ apps/frontend/src/app/app.routes.ts | 2 + .../src/app/core/services/auth.service.ts | 19 +- .../auth/change-password/change-password.html | 2 +- .../change-password/change-password.spec.ts | 17 +- .../auth/change-password/change-password.ts | 6 +- .../auth/forgot-password/forgot-password.html | 37 ++++ .../auth/forgot-password/forgot-password.scss | 104 +++++++++++ .../forgot-password/forgot-password.spec.ts | 75 ++++++++ .../auth/forgot-password/forgot-password.ts | 53 ++++++ .../src/app/features/auth/login/login.html | 2 + .../src/app/features/auth/login/login.scss | 10 + .../src/app/features/auth/login/login.spec.ts | 3 +- .../src/app/features/auth/login/login.ts | 4 +- .../auth/reset-password/reset-password.html | 30 +++ .../auth/reset-password/reset-password.scss | 104 +++++++++++ .../reset-password/reset-password.spec.ts | 74 ++++++++ .../auth/reset-password/reset-password.ts | 52 ++++++ .../src/app/shared/models/auth.model.ts | 9 + .../shared/validators/password.validator.ts | 15 ++ docker-compose.yml | 16 ++ .../31-contrat-authentification.md | 17 +- 48 files changed, 1914 insertions(+), 25 deletions(-) create mode 100644 apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py create mode 100644 apps/backend/app/core/mailer.py create mode 100644 apps/backend/app/models/password_reset_attempt.py create mode 100644 apps/backend/app/models/password_reset_token.py create mode 100644 apps/backend/app/repositories/password_reset_attempt.py create mode 100644 apps/backend/app/repositories/password_reset_token.py create mode 100644 apps/backend/tests/repositories/test_password_reset_token.py create mode 100644 apps/backend/tests/schemas/__init__.py create mode 100644 apps/backend/tests/schemas/test_auth.py create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.html create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.html create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.scss create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.ts create mode 100644 apps/frontend/src/app/shared/validators/password.validator.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index f36551e..8dff67f 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -8,3 +8,13 @@ APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision + +# Mot de passe oublié : lien à usage unique valable 15 minutes par défaut. +APP_FRONTEND_RESET_PASSWORD_URL=http://localhost:4200/reset-password + +# SMTP local de dev (Mailpit, cf. docker-compose.yml) : aucune authentification, aucun TLS. +# À remplacer par un vrai relais en staging/prod. +APP_SMTP_HOST=localhost +APP_SMTP_PORT=1025 +APP_SMTP_USE_TLS=false +APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr diff --git a/apps/backend/README.md b/apps/backend/README.md index 91f9608..6c48b3a 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -103,6 +103,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente | | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton | | `/api/v1/auth/password` | Change son propre mot de passe | jeton | +| `/api/v1/auth/forgot-password` | Demande un lien de réinitialisation par email | public | +| `/api/v1/auth/reset-password` | Choisit un nouveau mot de passe depuis ce lien | public | | `/api/v1/auth/me` | Décrit le compte connecté | jeton | | `/api/v1/users` | Liste et crée des comptes | `admin` | | `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` | diff --git a/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py b/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py new file mode 100644 index 0000000..7f75d21 --- /dev/null +++ b/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py @@ -0,0 +1,96 @@ +"""jetons et tentatives de reinitialisation de mot de passe + +Revision ID: c0adab96238c +Revises: e6d2026091501 +Create Date: 2026-09-17 10:37:12.571314 + +Meme schema que `refresh_token` pour `password_reset_token` : seule l'empreinte SHA-256 du +jeton est stockee, jamais le jeton lui-meme, pour la meme raison (revocation en cascade, +aucune session utilisable dans un pg_dump qui fuiterait). + +`password_reset_attempt` vit hors de `audit_log`, comme `login_attempt`, car son volume est +pilote par l'attaquant : une campagne de demandes y ecrirait des lignes que l'audit, en ajout +seul, ne devrait jamais purger. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "c0adab96238c" +down_revision: str | Sequence[str] | None = "e6d2026091501" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +JETONS_VIVANTS = "consumed_at is null" + + +def upgrade() -> None: + op.create_table( + "password_reset_attempt", + sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("email_tried", sa.String(length=320), nullable=False), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.PrimaryKeyConstraint("id", name="pk_password_reset_attempt"), + ) + op.create_index( + "ix_password_reset_attempt_email_date", + "password_reset_attempt", + ["email_tried", "occurred_at"], + ) + op.create_index( + "ix_password_reset_attempt_ip_date", "password_reset_attempt", ["client_ip", "occurred_at"] + ) + + op.create_table( + "password_reset_token", + sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("token_hash", sa.LargeBinary(), nullable=False), + sa.Column( + "issued_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["app_user.id"], + name="fk_password_reset_token_user", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name="pk_password_reset_token"), + sa.UniqueConstraint("token_hash", name="uq_password_reset_token_hash"), + ) + op.create_index("ix_password_reset_token_user", "password_reset_token", ["user_id"]) + op.create_index( + "ix_password_reset_token_vivants", + "password_reset_token", + ["user_id"], + postgresql_where=JETONS_VIVANTS, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_password_reset_token_vivants", + table_name="password_reset_token", + postgresql_where=JETONS_VIVANTS, + ) + op.drop_index("ix_password_reset_token_user", table_name="password_reset_token") + op.drop_table("password_reset_token") + op.drop_index("ix_password_reset_attempt_ip_date", table_name="password_reset_attempt") + op.drop_index("ix_password_reset_attempt_email_date", table_name="password_reset_attempt") + op.drop_table("password_reset_attempt") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 5b39098..f1aa8ae 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import Settings, get_settings from app.core.hashing import Argon2Hasher, build_hasher +from app.core.mailer import Mailer, SmtpConfig from app.core.principal import Principal from app.core.roles import AccountKind, Role, has_at_least from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy @@ -24,13 +25,15 @@ from app.db.session import get_session from app.repositories.alert import AlertRepository from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.password_reset_attempt import PasswordResetAttemptRepository +from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.reading import ReadingRepository from app.repositories.recommendation import RecommendationRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.site import SiteRepository from app.repositories.user import UserRepository from app.services.alert import AlertService -from app.services.auth import AuthService, LoginPolicy +from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy from app.services.recommendation import RecommendationService from app.services.sensor import SensorService from app.services.site import SiteService @@ -97,11 +100,27 @@ def get_client_ip(request: Request, settings: SettingsDep) -> str | None: return request.client.host if request.client else None +def get_mailer(settings: SettingsDep) -> Mailer: + return Mailer( + SmtpConfig( + host=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_username, + password=( + settings.smtp_password.get_secret_value() if settings.smtp_password else None + ), + use_tls=settings.smtp_use_tls, + from_address=settings.smtp_from_address, + ) + ) + + def get_auth_service( session: SessionDep, settings: SettingsDep, hasher: Annotated[Argon2Hasher, Depends(get_hasher)], token_policy: Annotated[TokenPolicy, Depends(get_token_policy)], + mailer: Annotated[Mailer, Depends(get_mailer)], ) -> AuthService: return AuthService( users=UserRepository(session), @@ -118,6 +137,16 @@ def get_auth_service( max_failures_per_identifier=settings.login_max_failures_per_identifier, ), refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds), + reset_tokens=PasswordResetTokenRepository(session), + reset_attempts=PasswordResetAttemptRepository(session), + reset_policy=PasswordResetPolicy( + window_seconds=settings.password_reset_window_seconds, + max_requests_per_identifier=settings.password_reset_max_requests_per_identifier, + max_requests_per_ip=settings.password_reset_max_requests_per_ip, + token_ttl=timedelta(seconds=settings.password_reset_ttl_seconds), + frontend_reset_url=settings.frontend_reset_password_url, + ), + mailer=mailer, ) diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 85b7775..a649705 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -156,3 +156,16 @@ REPONSE_ORIGINE_REFUSEE: Final[Reponses] = { "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", }, } + +REPONSE_LIMITE: Final[Reponses] = { + 429: { + "model": ErrorResponse, + "description": "Trop de demandes sur cette fenêtre glissante.", + "headers": { + "Retry-After": { + "description": "Secondes à attendre avant une nouvelle tentative.", + "schema": {"type": "integer"}, + } + }, + }, +} diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 32bf8b2..957775f 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -12,6 +12,7 @@ from app.api.deps import ( require_trusted_origin, ) from app.api.openapi import ( + REPONSE_LIMITE, REPONSE_ORIGINE_REFUSEE, REPONSE_VALIDATION, REPONSES_AUTHENTIFIEES, @@ -21,15 +22,18 @@ from app.api.openapi import ( from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger from app.schemas.auth import ( + ForgotPasswordRequest, LoginRequest, PasswordChangeRequest, PrincipalResponse, + ResetPasswordRequest, TokenResponse, ) from app.schemas.errors import ErrorResponse from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, RateLimitedError, SessionRejectedError, ) @@ -39,6 +43,7 @@ logger = get_logger(__name__) DETAIL_IDENTIFIANTS = "Identifiants invalides" DETAIL_SESSION = "Session invalide" +DETAIL_LIEN_RESET = "Lien invalide ou expiré" REPONSES_LOGIN: Reponses = { **REPONSE_VALIDATION, @@ -85,6 +90,20 @@ REPONSES_MOT_DE_PASSE: Reponses = { }, } +REPONSES_FORGOT_PASSWORD: Reponses = { + **REPONSE_VALIDATION, + **REPONSE_LIMITE, +} + +REPONSES_RESET_PASSWORD: Reponses = { + **REPONSE_VALIDATION, + **REPONSE_ORIGINE_REFUSEE, + 400: { + "model": ErrorResponse, + "description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).", + }, +} + def repond( response: Response, settings: SettingsDep, session: AuthenticatedSession @@ -267,3 +286,67 @@ async def change_password( logger.info("auth.password_changed user_id=%s", principal.id) return repond(response, settings, session) + + +@router.post( + "/forgot-password", + status_code=status.HTTP_202_ACCEPTED, + summary="Demande un lien de réinitialisation par email", + responses=REPONSES_FORGOT_PASSWORD, +) +async def forgot_password( + payload: ForgotPasswordRequest, + request: Request, + response: Response, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> None: + response.headers["Cache-Control"] = "no-store" + + try: + await service.request_password_reset( + email=payload.email, + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except RateLimitedError as erreur: + logger.warning("auth.password_reset.rate_limited ip=%s", client_ip) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Trop de demandes, réessayez plus tard", + headers={"Retry-After": str(erreur.retry_after)}, + ) from erreur + + +@router.post( + "/reset-password", + response_model=TokenResponse, + summary="Choisit un nouveau mot de passe depuis un lien reçu par email", + dependencies=[Depends(require_trusted_origin)], + responses=REPONSES_RESET_PASSWORD, +) +async def reset_password( + payload: ResetPasswordRequest, + request: Request, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + + try: + session = await service.confirm_password_reset( + token=payload.token, + new_password=payload.new_password, + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except InvalidOrExpiredResetTokenError as erreur: + logger.warning("auth.password_reset.invalid_token ip=%s", client_ip) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=DETAIL_LIEN_RESET + ) from erreur + + logger.info("auth.password_reset.success user_id=%s", session.principal.id) + return repond(response, settings, session) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py index 37e94fd..fea510e 100644 --- a/apps/backend/app/cli.py +++ b/apps/backend/app/cli.py @@ -9,6 +9,7 @@ import argparse import asyncio import json import secrets +import string import sys from getpass import getpass from pathlib import Path @@ -22,9 +23,10 @@ from app.core.roles import Role from app.db.session import get_session_factory from app.main import create_app from app.repositories.user import UserRepository +from app.schemas.auth import PASSWORD_MIN_LENGTH, valide_complexite LONGUEUR_MOT_DE_PASSE_GENERE = 24 -LONGUEUR_MINIMALE = 12 +CARACTERES_SPECIAUX = "!@#$%^&*()-_=+[]{};:,.?" CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json" @@ -111,15 +113,36 @@ def build_parser() -> argparse.ArgumentParser: return parser +def genere_mot_de_passe() -> str: + tirage = secrets.SystemRandom() + classes = [ + string.ascii_uppercase, + string.ascii_lowercase, + string.digits, + CARACTERES_SPECIAUX, + ] + reste = LONGUEUR_MOT_DE_PASSE_GENERE - len(classes) + caracteres = [tirage.choice(classe) for classe in classes] + caracteres += [tirage.choice("".join(classes)) for _ in range(reste)] + tirage.shuffle(caracteres) + return "".join(caracteres) + + def read_password(*, generate: bool) -> str: if generate: - mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE) + mot_de_passe = genere_mot_de_passe() print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}") return mot_de_passe mot_de_passe = getpass("Mot de passe : ") - if len(mot_de_passe) < LONGUEUR_MINIMALE: - raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères") + if len(mot_de_passe) < PASSWORD_MIN_LENGTH: + raise SystemExit( + f"Le mot de passe doit faire au moins {PASSWORD_MIN_LENGTH} caractères" + ) + try: + valide_complexite(mot_de_passe) + except ValueError as erreur: + raise SystemExit(str(erreur)) from erreur if mot_de_passe != getpass("Confirmation : "): raise SystemExit("Les deux saisies diffèrent") return mot_de_passe diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 6733b3a..e374709 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -54,6 +54,19 @@ class Settings(BaseSettings): login_max_failures_per_ip: int = Field(default=20, ge=1) login_max_failures_per_identifier: int = Field(default=50, ge=1) + password_reset_ttl_seconds: int = Field(default=900, ge=60, le=3600) + password_reset_window_seconds: int = Field(default=900, ge=60) + password_reset_max_requests_per_identifier: int = Field(default=3, ge=1) + password_reset_max_requests_per_ip: int = Field(default=10, ge=1) + + smtp_host: str = "localhost" + smtp_port: int = Field(default=587, ge=1, le=65535) + smtp_username: str | None = None + smtp_password: SecretStr | None = None + smtp_use_tls: bool = False + smtp_from_address: str = "no-reply@enervision.fr" + frontend_reset_password_url: str = "http://localhost:4200/reset-password" # noqa: S105 + trust_proxy_headers: bool = False expose_api_docs: bool | None = None metrics_token: SecretStr | None = None diff --git a/apps/backend/app/core/mailer.py b/apps/backend/app/core/mailer.py new file mode 100644 index 0000000..5c09008 --- /dev/null +++ b/apps/backend/app/core/mailer.py @@ -0,0 +1,48 @@ +# Piège : l'URL de réinitialisation porte le jeton en clair. Ne jamais la journaliser : +# `send_password_reset_email()` ne logue que le destinataire, jamais `reset_url`. + +from dataclasses import dataclass +from email.message import EmailMessage + +import aiosmtplib + +from app.core.logging import get_logger + +logger = get_logger(__name__) + + +@dataclass(frozen=True, slots=True) +class SmtpConfig: + host: str + port: int + username: str | None + password: str | None + use_tls: bool + from_address: str + + +class Mailer: + def __init__(self, config: SmtpConfig) -> None: + self._config = config + + async def send_password_reset_email(self, *, to: str, reset_url: str) -> None: + message = EmailMessage() + message["From"] = self._config.from_address + message["To"] = to + message["Subject"] = "Réinitialisation de votre mot de passe EnerVision" + message.set_content( + "Une réinitialisation de mot de passe a été demandée pour ce compte.\n\n" + f"Ouvrez ce lien dans les 15 minutes pour choisir un nouveau mot de passe : " + f"{reset_url}\n\n" + "Si vous n'êtes pas à l'origine de cette demande, ignorez cet email." + ) + + _, message_recu = await aiosmtplib.send( + message, + hostname=self._config.host, + port=self._config.port, + username=self._config.username, + password=self._config.password, + use_tls=self._config.use_tls, + ) + logger.info("mailer.password_reset_sent to=%s smtp_response=%s", to, message_recu) diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 10a5ecb..167d7ce 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -4,6 +4,8 @@ from app.models.audit_log import AuditLog from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site from app.models.login_attempt import LoginAttempt +from app.models.password_reset_attempt import PasswordResetAttempt +from app.models.password_reset_token import PasswordResetToken from app.models.refresh_token import RefreshToken from app.models.user import AppUser @@ -13,6 +15,8 @@ __all__ = [ "AuditLog", "Dataset", "LoginAttempt", + "PasswordResetAttempt", + "PasswordResetToken", "Prediction", "Reading", "Recommendation", diff --git a/apps/backend/app/models/audit_log.py b/apps/backend/app/models/audit_log.py index 5775f5e..d389880 100644 --- a/apps/backend/app/models/audit_log.py +++ b/apps/backend/app/models/audit_log.py @@ -29,6 +29,8 @@ class AuditAction(StrEnum): COMPTE_ACTIVE = "user.enabled" COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin" COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed" + MOT_DE_PASSE_OUBLIE_DEMANDE = "auth.password_reset_requested" + MOT_DE_PASSE_REINITIALISE_PAR_SOI = "auth.password_reset_self_service" REFRESH_REUTILISE = "auth.refresh_reuse_detected" SESSIONS_REVOQUEES = "auth.all_sessions_revoked" LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled" diff --git a/apps/backend/app/models/password_reset_attempt.py b/apps/backend/app/models/password_reset_attempt.py new file mode 100644 index 0000000..6d2a607 --- /dev/null +++ b/apps/backend/app/models/password_reset_attempt.py @@ -0,0 +1,27 @@ +# Pourquoi : même séparation que `login_attempt` par rapport à `audit_log` : ce compteur est +# piloté par l'attaquant (une campagne de demandes) et se purge, l'audit log est en ajout seul. +# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 apprendrait +# qu'un compte existe. + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Identity, Index, String, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PasswordResetAttempt(Base): + __tablename__ = "password_reset_attempt" + __table_args__ = ( + Index("ix_password_reset_attempt_email_date", "email_tried", "occurred_at"), + Index("ix_password_reset_attempt_ip_date", "client_ip", "occurred_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + email_tried: Mapped[str] = mapped_column(String(320), nullable=False) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) diff --git a/apps/backend/app/models/password_reset_token.py b/apps/backend/app/models/password_reset_token.py new file mode 100644 index 0000000..d67d310 --- /dev/null +++ b/apps/backend/app/models/password_reset_token.py @@ -0,0 +1,40 @@ +# Pourquoi : même schéma que `refresh_token` (chaîne opaque, jamais un JWT) pour la même +# raison : un jeton de réinitialisation doit être révocable d'un coup, et un JWT ne figure +# dans aucune ligne à invalider. + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, Text, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PasswordResetToken(Base): + __tablename__ = "password_reset_token" + __table_args__ = ( + Index("ix_password_reset_token_user", "user_id"), + Index( + "ix_password_reset_token_vivants", + "user_id", + postgresql_where="consumed_at is null", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + user_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False + ) + token_hash: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, unique=True) + issued_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/apps/backend/app/repositories/password_reset_attempt.py b/apps/backend/app/repositories/password_reset_attempt.py new file mode 100644 index 0000000..ddc2f91 --- /dev/null +++ b/apps/backend/app/repositories/password_reset_attempt.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.password_reset_attempt import PasswordResetAttempt + + +@dataclass(frozen=True, slots=True) +class ResetRequestCounts: + per_identifier: int + per_ip: int + + +class PasswordResetAttemptRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def record(self, *, email: str, client_ip: str | None) -> None: + self._session.add( + PasswordResetAttempt(email_tried=email.strip().lower(), client_ip=client_ip) + ) + + async def count_recent( + self, *, email: str, client_ip: str | None, window_seconds: int + ) -> ResetRequestCounts: + identifiant = email.strip().lower() + meme_email = PasswordResetAttempt.email_tried == identifiant + meme_ip = PasswordResetAttempt.client_ip == client_ip + + requete = select( + func.count().filter(meme_email), + func.count().filter(meme_ip), + ).where( + PasswordResetAttempt.occurred_at + > datetime.now(UTC) - timedelta(seconds=window_seconds), + meme_email | meme_ip, + ) + + par_identifiant, par_ip = (await self._session.execute(requete)).one() + return ResetRequestCounts(per_identifier=par_identifiant, per_ip=par_ip) diff --git a/apps/backend/app/repositories/password_reset_token.py b/apps/backend/app/repositories/password_reset_token.py new file mode 100644 index 0000000..13a660e --- /dev/null +++ b/apps/backend/app/repositories/password_reset_token.py @@ -0,0 +1,68 @@ +# Piège : `consume()` est une seule instruction, sur le modèle de `claim_for_rotation()` du +# jeton de rafraîchissement. Un SELECT puis un UPDATE laisseraient une fenêtre où deux +# soumissions concurrentes du même lien réussiraient toutes les deux. + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from sqlalchemy import func, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.password_reset_token import PasswordResetToken + + +@dataclass(frozen=True, slots=True) +class ConsumedResetToken: + id: UUID + user_id: UUID + + +class PasswordResetTokenRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create( + self, + *, + user_id: UUID, + token_hash: bytes, + expires_at: datetime, + client_ip: str | None, + user_agent: str | None, + ) -> PasswordResetToken: + jeton = PasswordResetToken( + user_id=user_id, + token_hash=token_hash, + expires_at=expires_at, + client_ip=client_ip, + user_agent=user_agent, + ) + self._session.add(jeton) + await self._session.flush() + return jeton + + async def consume(self, token_hash: bytes) -> ConsumedResetToken | None: + requete = ( + update(PasswordResetToken) + .where( + PasswordResetToken.token_hash == token_hash, + PasswordResetToken.consumed_at.is_(None), + PasswordResetToken.expires_at > func.clock_timestamp(), + ) + .values(consumed_at=func.clock_timestamp()) + .returning(PasswordResetToken.id, PasswordResetToken.user_id) + ) + ligne = (await self._session.execute(requete)).one_or_none() + if ligne is None: + return None + return ConsumedResetToken(id=ligne.id, user_id=ligne.user_id) + + async def invalidate_all_for_user(self, user_id: UUID) -> int: + resultat = await self._session.execute( + update(PasswordResetToken) + .where(PasswordResetToken.user_id == user_id, PasswordResetToken.consumed_at.is_(None)) + .values(consumed_at=func.clock_timestamp()) + .returning(PasswordResetToken.id) + ) + return len(resultat.all()) diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py index 522b4c5..e6785be 100644 --- a/apps/backend/app/schemas/auth.py +++ b/apps/backend/app/schemas/auth.py @@ -1,17 +1,39 @@ # Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix # mégaoctets ferait travailler Argon2 gratuitement, à la charge du serveur. +import re from typing import Literal, Self from uuid import UUID -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from app.core.principal import Principal from app.core.roles import AccountKind, Role -PASSWORD_MIN_LENGTH = 12 +PASSWORD_MIN_LENGTH = 8 PASSWORD_MAX_LENGTH = 128 +_MAJUSCULE = re.compile(r"[A-ZÀ-Ý]") +_MINUSCULE = re.compile(r"[a-zà-ÿ]") +_CHIFFRE = re.compile(r"\d") +_SPECIAL = re.compile(r"[^\w\s]") + + +def valide_complexite(mot_de_passe: str) -> str: + manquants = [ + nom + for nom, motif in ( + ("une majuscule", _MAJUSCULE), + ("une minuscule", _MINUSCULE), + ("un chiffre", _CHIFFRE), + ("un caractère spécial", _SPECIAL), + ) + if not motif.search(mot_de_passe) + ] + if manquants: + raise ValueError(f"Le mot de passe doit contenir au moins {', '.join(manquants)}") + return mot_de_passe + class LoginRequest(BaseModel): email: EmailStr @@ -22,6 +44,25 @@ class PasswordChangeRequest(BaseModel): current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH) new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + @field_validator("new_password") + @classmethod + def _new_password_est_complexe(cls, valeur: str) -> str: + return valide_complexite(valeur) + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + token: str = Field(min_length=1) + new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + + @field_validator("new_password") + @classmethod + def _new_password_est_complexe(cls, valeur: str) -> str: + return valide_complexite(valeur) + class PrincipalResponse(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 8baf857..02ff04b 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -15,6 +15,7 @@ from typing import NoReturn, Protocol from uuid import UUID, uuid4 from app.core.hashing import Argon2Hasher +from app.core.mailer import Mailer from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.core.security import ( @@ -28,6 +29,8 @@ from app.models.login_attempt import LoginOutcome from app.models.refresh_token import RevocationReason from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.password_reset_attempt import PasswordResetAttemptRepository +from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository @@ -54,6 +57,10 @@ class RateLimitedError(AuthError): self.retry_after = retry_after +class InvalidOrExpiredResetTokenError(AuthError): + pass + + @dataclass(frozen=True, slots=True) class LoginPolicy: window_seconds: int @@ -62,6 +69,15 @@ class LoginPolicy: max_failures_per_identifier: int +@dataclass(frozen=True, slots=True) +class PasswordResetPolicy: + window_seconds: int + max_requests_per_identifier: int + max_requests_per_ip: int + token_ttl: timedelta + frontend_reset_url: str + + @dataclass(frozen=True, slots=True) class AuthenticatedSession: principal: Principal @@ -83,6 +99,10 @@ class AuthService: token_policy: TokenPolicy, login_policy: LoginPolicy, refresh_ttl: timedelta, + reset_tokens: PasswordResetTokenRepository, + reset_attempts: PasswordResetAttemptRepository, + reset_policy: PasswordResetPolicy, + mailer: Mailer, ) -> None: self._users = users self._attempts = attempts @@ -93,6 +113,10 @@ class AuthService: self._token_policy = token_policy self._login_policy = login_policy self._refresh_ttl = refresh_ttl + self._reset_tokens = reset_tokens + self._reset_attempts = reset_attempts + self._reset_policy = reset_policy + self._mailer = mailer async def authenticate( self, *, email: str, password: str, client_ip: str | None, user_agent: str | None @@ -200,6 +224,75 @@ class AuthService: rafraichi = await self._users.get_by_id(principal.id) return self._session(self._en_principal(rafraichi or compte), secret) + async def request_password_reset( + self, *, email: str, client_ip: str | None, user_agent: str | None + ) -> None: + await self._refuse_si_limite_reset(email=email, client_ip=client_ip) + + compte = await self._users.get_by_email(email) + # Piège : le hachage factice équilibre le temps de réponse sur un compte inconnu, comme + # `authenticate()`. La réponse et sa forme restent identiques dans tous les cas : compte + # inconnu, compte inactif, ou email envoyé avec succès. + if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value: + await self._hasher.verify_dummy() + await self._reset_attempts.record(email=email, client_ip=client_ip) + await self._transaction.commit() + return + + await self._reset_tokens.invalidate_all_for_user(compte.id) + secret = generate_refresh_secret() + await self._reset_tokens.create( + user_id=compte.id, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + self._reset_policy.token_ttl, + client_ip=client_ip, + user_agent=user_agent, + ) + await self._reset_attempts.record(email=email, client_ip=client_ip) + await self._audit.record( + action=AuditAction.MOT_DE_PASSE_OUBLIE_DEMANDE, + actor_label=compte.email, + target_type="app_user", + target_id=str(compte.id), + client_ip=client_ip, + user_agent=user_agent, + ) + await self._transaction.commit() + + lien = f"{self._reset_policy.frontend_reset_url}?token={secret}" + await self._mailer.send_password_reset_email(to=compte.email, reset_url=lien) + + async def confirm_password_reset( + self, *, token: str, new_password: str, client_ip: str | None, user_agent: str | None + ) -> AuthenticatedSession: + revendique = await self._reset_tokens.consume(fingerprint_refresh(token)) + if revendique is None: + raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré") + + await self._users.update_password( + revendique.user_id, await self._hasher.hash(new_password), must_change_password=False + ) + revoquees = await self._refresh.revoke_all_for_user( + revendique.user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE + ) + secret = await self._ouvre_une_famille( + user_id=revendique.user_id, client_ip=client_ip, user_agent=user_agent + ) + await self._audit.record( + action=AuditAction.MOT_DE_PASSE_REINITIALISE_PAR_SOI, + target_type="app_user", + target_id=str(revendique.user_id), + client_ip=client_ip, + user_agent=user_agent, + detail={"sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + + compte = await self._users.get_by_id(revendique.user_id) + if compte is None: + raise SessionRejectedError("Compte introuvable") + return self._session(self._en_principal(compte), secret) + async def logout_all(self, principal: Principal) -> int: revoquees = await self._refresh.revoke_all_for_user( principal.id, RevocationReason.DECONNEXION @@ -307,6 +400,23 @@ class AuthService: await self._transaction.commit() raise RateLimitedError(politique.window_seconds) + async def _refuse_si_limite_reset(self, *, email: str, client_ip: str | None) -> None: + politique = self._reset_policy + compteurs = await self._reset_attempts.count_recent( + email=email, client_ip=client_ip, window_seconds=politique.window_seconds + ) + + depasse = ( + compteurs.per_identifier >= politique.max_requests_per_identifier + or compteurs.per_ip >= politique.max_requests_per_ip + ) + if not depasse: + return + + await self._reset_attempts.record(email=email, client_ip=client_ip) + await self._transaction.commit() + raise RateLimitedError(politique.window_seconds) + async def _echoue( self, email: str, diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 84f9c08..f142efd 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -424,6 +424,144 @@ ] } }, + "/api/v1/auth/forgot-password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Demande un lien de réinitialisation par email", + "operationId": "forgot_password_api_v1_auth_forgot_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "429": { + "description": "Trop de demandes sur cette fenêtre glissante.", + "headers": { + "Retry-After": { + "description": "Secondes à attendre avant une nouvelle tentative.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/reset-password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Choisit un nouveau mot de passe depuis un lien reçu par email", + "operationId": "reset_password_api_v1_auth_reset_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/users": { "get": { "tags": [ @@ -1432,6 +1570,20 @@ ], "title": "FieldError" }, + "ForgotPasswordRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + } + }, + "type": "object", + "required": [ + "email" + ], + "title": "ForgotPasswordRequest" + }, "InternalErrorResponse": { "properties": { "detail": { @@ -1511,7 +1663,7 @@ "new_password": { "type": "string", "maxLength": 128, - "minLength": 12, + "minLength": 8, "title": "New Password" } }, @@ -1619,6 +1771,27 @@ ], "title": "RecommendationResponse" }, + "ResetPasswordRequest": { + "properties": { + "token": { + "type": "string", + "minLength": 1, + "title": "Token" + }, + "new_password": { + "type": "string", + "maxLength": 128, + "minLength": 8, + "title": "New Password" + } + }, + "type": "object", + "required": [ + "token", + "new_password" + ], + "title": "ResetPasswordRequest" + }, "Role": { "type": "string", "enum": [ diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 18bf979..2bfdef3 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pyjwt>=2.10", "argon2-cffi>=23.1", "anyio>=4.0", + "aiosmtplib>=5.1.3", ] [dependency-groups] diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py index 1d734da..44c25e6 100644 --- a/apps/backend/tests/api/test_auth.py +++ b/apps/backend/tests/api/test_auth.py @@ -11,6 +11,7 @@ from app.core.roles import AccountKind, Role from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, RateLimitedError, SessionRejectedError, ) @@ -36,6 +37,14 @@ class FauxService: async def logout(self, **_: object) -> None: return None + async def request_password_reset(self, **_: object) -> None: + if self._erreur is not None: + raise self._erreur + return None + + async def confirm_password_reset(self, **_: object) -> AuthenticatedSession: + return await self.authenticate() + async def authenticate(self, **_: object) -> AuthenticatedSession: if self._erreur is not None: raise self._erreur @@ -206,3 +215,94 @@ async def test_a_cookie_bearing_route_accepts_a_request_without_origin( response = await client.post("/api/v1/auth/logout") assert response.status_code != 403 + + +async def test_forgot_password_answers_202_when_the_account_exists( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"} + ) + + assert response.status_code == 202 + assert response.headers["cache-control"] == "no-store" + + +async def test_forgot_password_answers_202_identically_when_the_account_is_unknown( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "inconnu@enervision.fr"} + ) + + assert response.status_code == 202 + + +async def test_forgot_password_returns_429_with_a_retry_after_when_the_rate_limit_is_reached( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = RateLimitedError(900) + + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"} + ) + + assert response.status_code == 429 + assert response.headers["retry-after"] == "900" + + +async def test_forgot_password_rejects_a_malformed_email( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/forgot-password", json={"email": "pas-un-email"}) + + assert response.status_code == 422 + + +async def test_reset_password_returns_the_token_and_the_cookie_on_success( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"}, + ) + + assert response.status_code == 200 + assert response.cookies.get("ev_refresh") is not None + assert "refresh_secret" not in response.text + + +async def test_reset_password_rejects_an_invalid_or_expired_token( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = InvalidOrExpiredResetTokenError("Lien invalide ou expiré") + + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-perime", "new_password": "Un-nouveau-mot-de-passe1!"}, + ) + + assert response.status_code == 400 + + +async def test_reset_password_rejects_a_weak_password( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-opaque", "new_password": "trop-simple"}, + ) + + assert response.status_code == 422 + + +async def test_reset_password_refuses_a_foreign_origin( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"}, + headers={"Origin": "https://malveillant.example"}, + ) + + assert response.status_code == 403 diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index 9a04338..1080dce 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -18,6 +18,10 @@ ROUTES_PUBLIQUES = frozenset( ("POST", "/api/v1/auth/login"), # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. ("POST", "/api/v1/auth/logout"), + ("POST", "/api/v1/auth/forgot-password"), + # Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune + # authentification préalable ne s'applique, c'est la validité du jeton qui tranche. + ("POST", "/api/v1/auth/reset-password"), ("GET", "/metrics"), } ) diff --git a/apps/backend/tests/repositories/test_password_reset_token.py b/apps/backend/tests/repositories/test_password_reset_token.py new file mode 100644 index 0000000..e25518c --- /dev/null +++ b/apps/backend/tests/repositories/test_password_reset_token.py @@ -0,0 +1,114 @@ +# Le premier test démontre l'atomicité de `consume()` : sur un double, deux soumissions +# concurrentes du même lien réussiraient toutes les deux. + +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.roles import Role +from app.core.security import fingerprint_refresh, generate_refresh_secret +from app.repositories.password_reset_token import PasswordResetTokenRepository +from app.repositories.user import UserRepository + +pytestmark = pytest.mark.integration + +DUREE = timedelta(minutes=15) + + +async def un_compte(session: AsyncSession) -> uuid.UUID: + compte = await UserRepository(session).create( + email=f"reset-{uuid.uuid4().hex[:12]}@enervision.fr", + password_hash="$argon2id$x", + role=Role.LECTEUR, + ) + return compte.id + + +async def un_jeton( + depot: PasswordResetTokenRepository, user_id: uuid.UUID, *, duree: timedelta = DUREE +) -> str: + secret = generate_refresh_secret() + await depot.create( + user_id=user_id, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + duree, + client_ip="203.0.113.10", + user_agent="pytest", + ) + return secret + + +async def test_consume_only_succeeds_once(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session)) + + premier = await depot.consume(fingerprint_refresh(secret)) + second = await depot.consume(fingerprint_refresh(secret)) + await session.rollback() + + assert premier is not None + assert second is None + + +async def test_consume_refuses_an_expired_token(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1)) + + revendique = await depot.consume(fingerprint_refresh(secret)) + await session.rollback() + + assert revendique is None + + +async def test_consume_returns_nothing_for_an_unknown_fingerprint( + session: AsyncSession, +) -> None: + revendique = await PasswordResetTokenRepository(session).consume( + fingerprint_refresh(generate_refresh_secret()) + ) + + assert revendique is None + + +async def test_invalidate_all_for_user_only_touches_living_tokens( + session: AsyncSession, +) -> None: + depot = PasswordResetTokenRepository(session) + compte = await un_compte(session) + await un_jeton(depot, compte) + await un_jeton(depot, compte) + + invalides = await depot.invalidate_all_for_user(compte) + second_passage = await depot.invalidate_all_for_user(compte) + await session.rollback() + + assert invalides == 2 + assert second_passage == 0 + + +async def test_the_database_refuses_two_tokens_sharing_a_fingerprint( + session: AsyncSession, +) -> None: + depot = PasswordResetTokenRepository(session) + compte = await un_compte(session) + secret = generate_refresh_secret() + await depot.create( + user_id=compte, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + DUREE, + client_ip=None, + user_agent=None, + ) + + with pytest.raises(IntegrityError): + await depot.create( + user_id=compte, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + DUREE, + client_ip=None, + user_agent=None, + ) + await session.rollback() diff --git a/apps/backend/tests/schemas/__init__.py b/apps/backend/tests/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/schemas/test_auth.py b/apps/backend/tests/schemas/test_auth.py new file mode 100644 index 0000000..7982f56 --- /dev/null +++ b/apps/backend/tests/schemas/test_auth.py @@ -0,0 +1,41 @@ +import pytest +from pydantic import ValidationError + +from app.schemas.auth import PasswordChangeRequest, valide_complexite + +MOT_DE_PASSE_VALIDE = "Un-mot-de-passe1!" + + +def test_password_change_request_accepts_a_password_covering_the_four_classes() -> None: + requete = PasswordChangeRequest( + current_password="peu-importe", new_password=MOT_DE_PASSE_VALIDE + ) + + assert requete.new_password == MOT_DE_PASSE_VALIDE + + +@pytest.mark.parametrize( + "new_password", + [ + "un-mot-de-passe1!", + "UN-MOT-DE-PASSE1!", + "Un-mot-de-passe!", + "Un mot de passe 1", + ], + ids=["sans_majuscule", "sans_minuscule", "sans_chiffre", "sans_caractere_special"], +) +def test_password_change_request_rejects_a_password_missing_a_character_class( + new_password: str, +) -> None: + with pytest.raises(ValidationError): + PasswordChangeRequest(current_password="peu-importe", new_password=new_password) + + +def test_password_change_request_rejects_a_password_below_the_minimum_length() -> None: + with pytest.raises(ValidationError): + PasswordChangeRequest(current_password="peu-importe", new_password="Ab1!") + + +def test_valide_complexite_names_every_missing_class_in_the_error() -> None: + with pytest.raises(ValueError, match=r"majuscule.*chiffre|chiffre.*majuscule"): + valide_complexite("minuscules-seulement") diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 9b8c42c..52cde71 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -16,11 +16,15 @@ from app.core.security import ( from app.models.login_attempt import LoginOutcome from app.models.refresh_token import RevocationReason from app.repositories.login_attempt import FailureCounts +from app.repositories.password_reset_attempt import ResetRequestCounts +from app.repositories.password_reset_token import ConsumedResetToken from app.repositories.refresh_token import ClaimedToken from app.services.auth import ( AuthService, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, LoginPolicy, + PasswordResetPolicy, RateLimitedError, SessionRejectedError, ) @@ -37,6 +41,13 @@ POLITIQUE_CONNEXION = LoginPolicy( max_failures_per_ip=20, max_failures_per_identifier=50, ) +POLITIQUE_RESET = PasswordResetPolicy( + window_seconds=900, + max_requests_per_identifier=3, + max_requests_per_ip=10, + token_ttl=timedelta(minutes=15), + frontend_reset_url="http://localhost:4200/reset-password", +) @dataclass @@ -168,6 +179,43 @@ class FausseTransaction: self.validations += 1 +class FauxDepotJetonsReset: + def __init__(self, revendique: ConsumedResetToken | None = None) -> None: + self.revendique = revendique + self.crees: list[UUID] = [] + self.invalidations: list[UUID] = [] + + async def create(self, *, user_id: UUID, **_: object) -> None: + self.crees.append(user_id) + + async def consume(self, token_hash: bytes) -> ConsumedResetToken | None: + return self.revendique + + async def invalidate_all_for_user(self, user_id: UUID) -> int: + self.invalidations.append(user_id) + return len(self.invalidations) + + +class FauxDepotTentativesReset: + def __init__(self, compteurs: ResetRequestCounts | None = None) -> None: + self.compteurs = compteurs or ResetRequestCounts(0, 0) + self.enregistrees: list[str] = [] + + async def count_recent(self, **_: object) -> ResetRequestCounts: + return self.compteurs + + async def record(self, *, email: str, **_: object) -> None: + self.enregistrees.append(email) + + +class FauxMailer: + def __init__(self) -> None: + self.envois: list[tuple[str, str]] = [] + + async def send_password_reset_email(self, *, to: str, reset_url: str) -> None: + self.envois.append((to, reset_url)) + + @dataclass class Attirail: service: AuthService @@ -176,6 +224,9 @@ class Attirail: jetons: FauxDepotJetons audit: FauxDepotAudit hacheur: FauxHacheur + jetons_reset: FauxDepotJetonsReset + tentatives_reset: FauxDepotTentativesReset + mailer: FauxMailer def fabrique_service( @@ -184,12 +235,17 @@ def fabrique_service( compteurs: FailureCounts | None = None, hacheur: FauxHacheur | None = None, jetons: FauxDepotJetons | None = None, + jetons_reset: FauxDepotJetonsReset | None = None, + compteurs_reset: ResetRequestCounts | None = None, ) -> Attirail: comptes = FauxDepotComptes(compte) tentatives = FauxDepotTentatives(compteurs) depot_jetons = jetons or FauxDepotJetons() audit = FauxDepotAudit() hacheur = hacheur or FauxHacheur() + depot_jetons_reset = jetons_reset or FauxDepotJetonsReset() + tentatives_reset = FauxDepotTentativesReset(compteurs_reset) + mailer = FauxMailer() service = AuthService( users=comptes, # type: ignore[arg-type] attempts=tentatives, # type: ignore[arg-type] @@ -200,8 +256,22 @@ def fabrique_service( token_policy=POLITIQUE_JETON, login_policy=POLITIQUE_CONNEXION, refresh_ttl=timedelta(days=7), + reset_tokens=depot_jetons_reset, # type: ignore[arg-type] + reset_attempts=tentatives_reset, # type: ignore[arg-type] + reset_policy=POLITIQUE_RESET, + mailer=mailer, # type: ignore[arg-type] + ) + return Attirail( + service, + comptes, + tentatives, + depot_jetons, + audit, + hacheur, + depot_jetons_reset, + tentatives_reset, + mailer, ) - return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur) async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object: @@ -493,3 +563,89 @@ async def test_change_password_refuses_a_wrong_current_password() -> None: assert attirail.jetons.revocations_par_compte == [] assert attirail.jetons.crees == [] + + +async def test_request_password_reset_emails_a_link_when_the_account_exists() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + + await attirail.service.request_password_reset( + email=compte.email, client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.jetons_reset.invalidations == [compte.id] + assert attirail.jetons_reset.crees == [compte.id] + assert len(attirail.mailer.envois) == 1 + assert attirail.mailer.envois[0][0] == compte.email + assert "auth.password_reset_requested" in attirail.audit.lignes[0][0] + + +async def test_request_password_reset_stays_silent_when_the_account_is_unknown() -> None: + attirail = fabrique_service(compte=None) + + await attirail.service.request_password_reset( + email="inconnu@enervision.fr", client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.jetons_reset.crees == [] + assert attirail.mailer.envois == [] + assert attirail.hacheur.verifications == 1, "le hachage factice doit tout de même tourner" + + +async def test_request_password_reset_stays_silent_when_the_account_is_inactive() -> None: + compte = FauxCompte(is_active=False) + attirail = fabrique_service(compte=compte) + + await attirail.service.request_password_reset( + email=compte.email, client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.jetons_reset.crees == [] + assert attirail.mailer.envois == [] + + +async def test_request_password_reset_raises_when_the_rate_limit_is_reached() -> None: + attirail = fabrique_service(compteurs_reset=ResetRequestCounts(per_identifier=3, per_ip=0)) + + with pytest.raises(RateLimitedError): + await attirail.service.request_password_reset( + email="operateur@enervision.fr", client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.mailer.envois == [] + + +async def test_confirm_password_reset_revokes_every_session_then_reopens_the_current_one() -> None: + compte = FauxCompte() + jetons_reset = FauxDepotJetonsReset( + revendique=ConsumedResetToken(id=uuid4(), user_id=compte.id) + ) + attirail = fabrique_service(compte=compte, jetons_reset=jetons_reset) + + session = await attirail.service.confirm_password_reset( + token="un-secret-opaque", + new_password="Un-nouveau-mot-de-passe1!", + client_ip="203.0.113.10", + user_agent="pytest", + ) + + assert attirail.jetons.revocations_par_compte == [ + (compte.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value) + ] + assert len(attirail.jetons.crees) == 1 + assert session.refresh_secret + assert "auth.password_reset_self_service" in attirail.audit.lignes[0][0] + + +async def test_confirm_password_reset_rejects_an_invalid_or_expired_token() -> None: + attirail = fabrique_service(jetons_reset=FauxDepotJetonsReset(revendique=None)) + + with pytest.raises(InvalidOrExpiredResetTokenError): + await attirail.service.confirm_password_reset( + token="un-secret-invalide", + new_password="Un-nouveau-mot-de-passe1!", + client_ip=None, + user_agent=None, + ) + + assert attirail.jetons.revocations_par_compte == [] diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py index 40b8317..7344bf7 100644 --- a/apps/backend/tests/test_cli.py +++ b/apps/backend/tests/test_cli.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest from app import cli +from app.schemas.auth import valide_complexite def test_build_parser_reads_the_create_admin_arguments() -> None: @@ -34,26 +35,36 @@ def test_read_password_generates_a_long_secret_when_asked( assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE assert mot_de_passe in capsys.readouterr().out + valide_complexite(mot_de_passe) def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None: - saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"]) + saisies = iter(["Un-mot-de-passe-valide1", "Un-mot-de-passe-valide1"]) monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) - assert cli.read_password(generate=False) == "un-mot-de-passe-valide" + assert cli.read_password(generate=False) == "Un-mot-de-passe-valide1" def test_read_password_refuses_a_password_below_the_minimum_length( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(cli, "getpass", lambda _: "court") + monkeypatch.setattr(cli, "getpass", lambda _: "Court1!") + + with pytest.raises(SystemExit): + cli.read_password(generate=False) + + +def test_read_password_refuses_a_password_missing_a_character_class( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(cli, "getpass", lambda _: "un-mot-de-passe-sans-majuscule-ni-chiffre") with pytest.raises(SystemExit): cli.read_password(generate=False) def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None: - saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"]) + saisies = iter(["Un-mot-de-passe-valide1", "Un-autre-mot-de-passe2"]) monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) with pytest.raises(SystemExit): diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index 7c2b8f4..39ec7ca 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = "==3.14.*" +[[package]] +name = "aiosmtplib" +version = "5.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/5c/9cabc5db6d607616e81ba6d8f1f231cd5a75955807a308c1090a59072d6d/aiosmtplib-5.1.3.tar.gz", hash = "sha256:ac2b418d3260ba62d9cfd0fe7359726e9dc009a4e8e8d9909fdfae332f522a7c", size = 77010, upload-time = "2026-09-08T02:11:20.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/0a/b56ab8163d54960337fdca475d3dfd56c8badf6172e79cf2ad00d5335dc1/aiosmtplib-5.1.3-py3-none-any.whl", hash = "sha256:f7d76ce3d4995a65a178c1f11e1bd1607706b921d00cb768e7a2c7f7ef5517a8", size = 30116, upload-time = "2026-09-08T02:11:19.352Z" }, +] + [[package]] name = "alembic" version = "1.20.0" @@ -306,6 +315,7 @@ name = "enervision-backend" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiosmtplib" }, { name = "alembic" }, { name = "anyio" }, { name = "argon2-cffi" }, @@ -332,6 +342,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiosmtplib", specifier = ">=5.1.3" }, { name = "alembic", specifier = ">=1.20.0" }, { name = "anyio", specifier = ">=4.0" }, { name = "argon2-cffi", specifier = ">=23.1" }, diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index b3e97d8..72e20f5 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -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], diff --git a/apps/frontend/src/app/core/services/auth.service.ts b/apps/frontend/src/app/core/services/auth.service.ts index d27c1db..9aa477a 100644 --- a/apps/frontend/src/app/core/services/auth.service.ts +++ b/apps/frontend/src/app/core/services/auth.service.ts @@ -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,14 @@ export class AuthService { me(): Observable { return this.http.get(`${environment.apiUrl}/auth/me`); } + + forgotPassword(payload: ForgotPasswordRequest): Observable { + return this.http.post(`${environment.apiUrl}/auth/forgot-password`, payload); + } + + resetPassword(payload: ResetPasswordRequest): Observable { + return this.http + .post(`${environment.apiUrl}/auth/reset-password`, payload, { withCredentials: true }) + .pipe(tap((response) => this.setSession(response))); + } } diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.html b/apps/frontend/src/app/features/auth/change-password/change-password.html index edf2146..d7b5039 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.html +++ b/apps/frontend/src/app/features/auth/change-password/change-password.html @@ -18,7 +18,7 @@ formControlName="new_password" autocomplete="new-password" /> - 12 à 128 caractères + {{ passwordHint }} @if (errorMessage()) {

{{ errorMessage() }}

diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts index 63e1872..0e72843 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -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!', }); }); diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts index 507af14..528aea0 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -2,6 +2,7 @@ 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'; +import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; @Component({ selector: 'app-change-password', @@ -17,10 +18,11 @@ export class ChangePassword { errorMessage = signal(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 { @@ -34,7 +36,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}).`); }, }); } diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html new file mode 100644 index 0000000..2bd7ef9 --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html @@ -0,0 +1,37 @@ +
+
+

Mot de passe oublié

+

Recevez un lien de réinitialisation par email

+ + @if (submitted()) { +

+ Si un compte existe pour cet email, un lien de réinitialisation vient d'être envoyé. + Il expire dans 15 minutes. +

+ } @else { + + + + @if (errorMessage()) { +

+ {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } +

+ } + + + } + + +
+
diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss new file mode 100644 index 0000000..31c9efc --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss @@ -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; + } +} diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts new file mode 100644 index 0000000..56f7764 --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts @@ -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 }; + let routerMock: { navigate: ReturnType }; + + 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); + }); +}); diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts new file mode 100644 index 0000000..6ceef5c --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts @@ -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(null); + retryAfterSeconds = signal(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); + }, + }); + } +} diff --git a/apps/frontend/src/app/features/auth/login/login.html b/apps/frontend/src/app/features/auth/login/login.html index 0083bd2..3ee100b 100644 --- a/apps/frontend/src/app/features/auth/login/login.html +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -32,5 +32,7 @@ + + diff --git a/apps/frontend/src/app/features/auth/login/login.scss b/apps/frontend/src/app/features/auth/login/login.scss index cc415b8..45b28c0 100644 --- a/apps/frontend/src/app/features/auth/login/login.scss +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -79,3 +79,13 @@ color: #dc2626; font-size: 0.85rem; } + +.auth-link { + margin-top: 1rem; + font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } +} diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts index 3c9bac1..d39298d 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; @@ -20,6 +20,7 @@ describe('Login', () => { providers: [ { provide: AuthService, useValue: authMock }, { provide: Router, useValue: routerMock }, + { provide: ActivatedRoute, useValue: {} }, ], }).compileComponents(); }); diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts index 34b9ff2..871e7cc 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -1,13 +1,13 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; @Component({ selector: 'app-login', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, RouterLink], templateUrl: './login.html', styleUrl: './login.scss', }) diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.html b/apps/frontend/src/app/features/auth/reset-password/reset-password.html new file mode 100644 index 0000000..eed77a8 --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.html @@ -0,0 +1,30 @@ +
+
+

Nouveau mot de passe

+ + @if (!hasToken) { +

Ce lien est incomplet. Redemandez un lien de réinitialisation.

+ } @else { +

Choisissez votre nouveau mot de passe

+ + + + {{ passwordHint }} + + @if (errorMessage()) { +

{{ errorMessage() }}

+ } + + + } + + +
+
diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.scss b/apps/frontend/src/app/features/auth/reset-password/reset-password.scss new file mode 100644 index 0000000..31c9efc --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.scss @@ -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; + } +} diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts new file mode 100644 index 0000000..7e212cd --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts @@ -0,0 +1,74 @@ +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'; + +function configure(token: string | null) { + return TestBed.configureTestingModule({ + imports: [ResetPassword, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: { resetPassword: vi.fn() } }, + { provide: Router, useValue: { navigate: vi.fn() } }, + { + provide: ActivatedRoute, + useValue: { snapshot: { queryParamMap: convertToParamMap(token ? { token } : {}) } }, + }, + ], + }).compileComponents(); +} + +describe('ResetPassword', () => { + it("signale un lien incomplet quand le jeton est absent de l'URL", async () => { + await configure(null); + const fixture = TestBed.createComponent(ResetPassword); + + expect(fixture.componentInstance.hasToken).toBe(false); + }); + + 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 }; + 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 }; + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; + 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('affiche un message dédié 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 }; + component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); + auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 }))); + + component.onSubmit(); + + expect(component.errorMessage()).toContain('invalide'); + }); +}); diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts new file mode 100644 index 0000000..6754fa7 --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts @@ -0,0 +1,52 @@ +import { Component, inject, signal } from '@angular/core'; +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'; + +@Component({ + selector: 'app-reset-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink], + templateUrl: './reset-password.html', + styleUrl: './reset-password.scss', +}) +export class ResetPassword { + 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(null); + isLoading = signal(false); + passwordHint = PASSWORD_HINT; + hasToken = this.token.length > 0; + + form = this.fb.nonNullable.group({ + new_password: ['', passwordValidators], + }); + + 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.errorMessage.set('Ce lien est invalide, déjà utilisé, ou a expiré. Redemandez-en un.'); + return; + } + this.errorMessage.set(`Nouveau mot de passe invalide (${this.passwordHint}).`); + }, + }); + } +} diff --git a/apps/frontend/src/app/shared/models/auth.model.ts b/apps/frontend/src/app/shared/models/auth.model.ts index 932572f..ebed0d5 100644 --- a/apps/frontend/src/app/shared/models/auth.model.ts +++ b/apps/frontend/src/app/shared/models/auth.model.ts @@ -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; diff --git a/apps/frontend/src/app/shared/validators/password.validator.ts b/apps/frontend/src/app/shared/validators/password.validator.ts new file mode 100644 index 0000000..fac1359 --- /dev/null +++ b/apps/frontend/src/app/shared/validators/password.validator.ts @@ -0,0 +1,15 @@ +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 PASSWORD_PATTERN = /^(?=.*[A-ZÀ-Ý])(?=.*[a-zà-ÿ])(?=.*\d)(?=.*[^\w\s]).*$/; + +export const passwordValidators = [ + Validators.required, + Validators.minLength(PASSWORD_MIN_LENGTH), + Validators.maxLength(PASSWORD_MAX_LENGTH), + Validators.pattern(PASSWORD_PATTERN), +]; diff --git a/docker-compose.yml b/docker-compose.yml index 3d0ea63..3f7f9ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,11 +27,22 @@ services: start_period: 40s restart: unless-stopped + # Piege : Mailpit ne relaie rien vers l'exterieur, il capture tout email envoye par le + # backend. Aucun acces reseau sortant n'est requis ; l'UI web (8025) sert a lire les emails. + mailpit: + image: axllent/mailpit + ports: + - "${MAILPIT_SMTP_PORT:-1025}:1025" + - "${MAILPIT_UI_PORT:-8025}:8025" + restart: unless-stopped + backend: build: ./apps/backend depends_on: db: condition: service_healthy + mailpit: + condition: service_started environment: APP_ENV: ${APP_ENV:-local} APP_DEBUG: ${APP_DEBUG:-false} @@ -39,6 +50,11 @@ services: APP_SECRET_KEY: ${APP_SECRET_KEY:?} APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password} + APP_SMTP_HOST: mailpit + APP_SMTP_PORT: "1025" + APP_SMTP_USE_TLS: "false" + APP_SMTP_FROM_ADDRESS: ${APP_SMTP_FROM_ADDRESS:-no-reply@enervision.fr} ports: - "${BACKEND_PORT:-8000}:8000" restart: unless-stopped diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md index 9c9fe66..981cd85 100644 --- a/docs/architecture/31-contrat-authentification.md +++ b/docs/architecture/31-contrat-authentification.md @@ -20,6 +20,8 @@ gérer : il suffit d'envoyer les requêtes avec `withCredentials`. | POST | `/api/v1/auth/logout` | cookie | `204` | | POST | `/api/v1/auth/logout-all` | jeton d'accès | `204` | | POST | `/api/v1/auth/password` | jeton d'accès | `200` `TokenResponse` | +| POST | `/api/v1/auth/forgot-password` | aucune | `202` (toujours, que le compte existe ou non) | +| POST | `/api/v1/auth/reset-password` | aucune (jeton dans le corps) | `200` `TokenResponse` | | GET | `/api/v1/auth/me` | jeton d'accès | `200` `PrincipalResponse` | | GET | `/api/v1/users` | jeton d'accès, `admin` | `200` `UserResponse[]` | | POST | `/api/v1/users` | jeton d'accès, `admin` | `201` `TemporaryPasswordResponse` | @@ -51,7 +53,17 @@ codes d'erreur ci-dessous reste la référence de comportement, le schéma celle } // POST /auth/password -{ "current_password": "...", "new_password": "..." } // 12 à 128 caractères +{ "current_password": "...", "new_password": "..." } // 8 à 128 caractères, au moins 1 majuscule, 1 minuscule, 1 chiffre, 1 caractère spécial + +// POST /auth/forgot-password +{ "email": "operateur@enervision.fr" } +// Répond toujours 202, sans corps, que le compte existe, soit inactif, ou soit inconnu. + +// POST /auth/reset-password +{ "token": "...", "new_password": "..." } // même règle de complexité que /auth/password +// Le jeton vient du lien reçu par email, valable 15 minutes, à usage unique. Répond +// TokenResponse au succès (l'appareil qui pose le nouveau mot de passe reste connecté), ou 400 +// si le jeton est invalide, déjà utilisé, ou expiré. ``` Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la réponse. @@ -70,6 +82,9 @@ Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la répon | `403` avec `detail: "Droits insuffisants"` | rôle trop bas | masquer ou griser l'action, ne pas déconnecter | | `403` sur `/auth/refresh`, `/logout`, `/logout-all`, `/password` | origine hors liste autorisée (voir « Origines autorisées ») | erreur de configuration réseau, pas un cas à gérer par l'utilisateur | | `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée | +| `429` sur `/auth/forgot-password` | trop de demandes | afficher l'attente, l'en-tête `Retry-After` donne les secondes | +| `400` sur `/auth/reset-password` | lien invalide, déjà utilisé, ou expiré | inviter à redemander un lien depuis `/forgot-password` | +| `403` sur `/auth/reset-password` | origine hors liste autorisée | erreur de configuration réseau, pas un cas à gérer par l'utilisateur | ## Les quatre règles qui comptent From 9c78c6dc3882d12c3ce8958a80a8aad1b30ca7b2 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 11:14:02 +0200 Subject: [PATCH 04/47] =?UTF-8?q?feat(frontend):=20design=20syst=C3=A8me?= =?UTF-8?q?=20-=20tokens,=20composants=20ui=20et=20restylage=20des=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralise les couleurs/rayons/espacements dispersés en dur dans chaque page (login, change-password, dashboard) en tokens CSS partagés, ajoute un petit set de composants standalone réutilisables (ev-button, ev-card, ev-alert, ev-badge) et intègre le logo EnerVision en en-tête des pages ainsi que dans Swagger/ReDoc côté backend. Refs #91 --- apps/backend/app/main.py | 50 ++++++++- apps/backend/app/static/logo-icon.png | Bin 0 -> 36468 bytes apps/frontend/README.md | 7 ++ apps/frontend/public/logo.png | Bin 0 -> 7152 bytes .../auth/change-password/change-password.html | 53 +++++---- .../auth/change-password/change-password.scss | 104 +++++------------- .../change-password/change-password.spec.ts | 4 +- .../auth/change-password/change-password.ts | 5 +- .../src/app/features/auth/login/login.html | 63 ++++++----- .../src/app/features/auth/login/login.scss | 100 ++++++----------- .../src/app/features/auth/login/login.spec.ts | 6 +- .../src/app/features/auth/login/login.ts | 5 +- .../src/app/features/dashboard/dashboard.html | 27 +++-- .../src/app/features/dashboard/dashboard.scss | 96 +++++----------- .../src/app/features/dashboard/dashboard.ts | 18 ++- .../app/shared/components/ui/alert/alert.html | 1 + .../app/shared/components/ui/alert/alert.scss | 27 +++++ .../shared/components/ui/alert/alert.spec.ts | 30 +++++ .../app/shared/components/ui/alert/alert.ts | 21 ++++ .../app/shared/components/ui/badge/badge.html | 3 + .../app/shared/components/ui/badge/badge.scss | 27 +++++ .../shared/components/ui/badge/badge.spec.ts | 30 +++++ .../app/shared/components/ui/badge/badge.ts | 13 +++ .../shared/components/ui/button/button.html | 3 + .../shared/components/ui/button/button.scss | 51 +++++++++ .../components/ui/button/button.spec.ts | 50 +++++++++ .../app/shared/components/ui/button/button.ts | 15 +++ .../app/shared/components/ui/card/card.html | 1 + .../app/shared/components/ui/card/card.scss | 10 ++ .../shared/components/ui/card/card.spec.ts | 22 ++++ .../src/app/shared/components/ui/card/card.ts | 9 ++ apps/frontend/src/styles.scss | 9 +- apps/frontend/src/styles/_forms.scss | 37 +++++++ apps/frontend/src/styles/_tokens.scss | 39 +++++++ .../32-design-systeme-frontend.md | 76 +++++++++++++ 35 files changed, 724 insertions(+), 288 deletions(-) create mode 100644 apps/backend/app/static/logo-icon.png create mode 100644 apps/frontend/public/logo.png create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.html create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.scss create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.ts create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.html create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.scss create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.ts create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.html create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.scss create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.ts create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.html create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.scss create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.ts create mode 100644 apps/frontend/src/styles/_forms.scss create mode 100644 apps/frontend/src/styles/_tokens.scss create mode 100644 docs/architecture/32-design-systeme-frontend.md diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index de1235e..5a8ec3e 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -1,9 +1,15 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from pathlib import Path 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.openapi.utils import get_openapi +from fastapi.staticfiles import StaticFiles from prometheus_fastapi_instrumentator import Instrumentator +from starlette.requests import Request +from starlette.responses import HTMLResponse from app.api.errors import register_error_handlers from app.api.middleware import SecurityHeadersMiddleware @@ -18,6 +24,8 @@ logger = get_logger(__name__) METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] EN_TETES_AUTORISES = ["Authorization", "Content-Type"] +STATIC_DIR = Path(__file__).parent / "static" +LOGO_URL = "/static/logo-icon.png" @asynccontextmanager @@ -43,11 +51,49 @@ def create_app(settings: Settings | None = None) -> FastAPI: openapi_tags=TAGS, debug=resolved.debug, lifespan=lifespan, - docs_url="/docs" if documentee else None, - redoc_url="/redoc" if documentee else None, + docs_url=None, + redoc_url=None, openapi_url="/openapi.json" if documentee else None, ) + if documentee: + application.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + # ReDoc supporte nativement `info.x-logo` (extension Redocly) pour afficher un logo + # en en-tête ; Swagger UI n'a pas d'equivalent, il ne reprend que le favicon. + def openapi_avec_logo() -> dict[str, object]: + if application.openapi_schema: + return application.openapi_schema + schema = get_openapi( + title=application.title, + version=application.version, + summary=application.summary, + description=application.description, + routes=application.routes, + tags=application.openapi_tags, + ) + schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"} + application.openapi_schema = schema + return application.openapi_schema + + application.openapi = openapi_avec_logo # type: ignore[method-assign] + + @application.get("/docs", include_in_schema=False) + async def docs_swagger(_: Request) -> HTMLResponse: + return get_swagger_ui_html( + openapi_url="/openapi.json", + title=f"{application.title} · Swagger UI", + swagger_favicon_url=LOGO_URL, + ) + + @application.get("/redoc", include_in_schema=False) + async def docs_redoc(_: Request) -> HTMLResponse: + return get_redoc_html( + openapi_url="/openapi.json", + title=f"{application.title} · ReDoc", + redoc_favicon_url=LOGO_URL, + ) + application.add_middleware(SecurityHeadersMiddleware) if resolved.allowed_origins: diff --git a/apps/backend/app/static/logo-icon.png b/apps/backend/app/static/logo-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d3bdc53f943206607cea054067c3888aa9056d83 GIT binary patch literal 36468 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 kzNePZU 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 literal 0 HcmV?d00001 diff --git a/apps/frontend/README.md b/apps/frontend/README.md index aeaf788..c5b7484 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -76,6 +76,13 @@ Points à vérifier après toute regénération : côté backend. Le `docker-compose.yml` n'a aucun service frontend. 4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx). +## Design système + +Tokens (couleurs, typo, espacements) et composants partagés (`ev-button`, `ev-card`, +`ev-alert`, `ev-badge`) sont documentés dans +[`docs/architecture/32-design-systeme-frontend.md`](../../docs/architecture/32-design-systeme-frontend.md). +Toute nouvelle page doit les réutiliser plutôt que définir ses propres valeurs. + ## Additional Resources For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/apps/frontend/public/logo.png b/apps/frontend/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e5e41b24ad30754b294062a27d7f16819fc51210 GIT binary patch literal 7152 zcmcIp=QrHn*Zmm16P+*!qW9>Xh!!oPm(fLyUWbXO(M9jQ*Dphe8oh_8BZ%l-Mj74X z_eVTy?Q`#&v(CCN&OUeTeWJBB-w@)_;sF3asH&o*`*im_1q=u4=`;(oa{>UmR#hc= zJwL?Jia$(mE|>505)bQRv0AL>L+<}y*y)C?dfJaOas#$maz+3Ru*Azx36qDQRB}=Ilu=&z zYa%|$FF%IbDgFhV8NWU*tI>l z(~5Eh3C1urok7qR1jSBDfr>J5hj2{Tb_?mw*P?29E0HFe5smU^NmE7b(4GjEqZfbQ zVmM-Nw60USsrowkeq>MF?)^4&lyimlv${cKMfy;1JKO(R2`Y(n4!aEmtV-Rv&C$oE$P4V1`G#O@G&2_NvFIMRrYD!IaDTnKQSB z&I#r(TKSHw8oy6VU8D(DWh7V%ayeE#JI~Ho*s?Tr{M%1)o(qaK=UP_Y!H-SMp&%X- z2h8s&!{Y0p0{yEuYtYF7!TtO3m;l8Lnid<22sp}5B>5;~?S6lm-u{mM1-6c^xg|tK zU9Cg~FGJg8#<1adkg&A~^G6)D_)9%t7B6JOOz}at|3!C7m#)Z91nn()>Hg!tHZAE^ zVmVu391?*4+ukXtqZmKM3A8YiyW9%+<^`IcL0{z2qsDZk z9v*b0eAZ9%!4hS3oZj)(X=*MqH^+r!Ss{W&+8lR%zQQX)&d-4o#GlTMPz#>By_dhL zE~ngO=@4LG@cyI2VSjK5&sv)5MQaU@vp`PwgHSzp(DDz9Bm7O{ z!oWwdIiE=y+fqTLzg(P^5fOo(34P?6KWH1YPQVosx;xj=B~+bKPv)|9zfEH#{acaz zj$bGH2lQt}3u@d;c1-J|NkVF7{yQd8dL8`T72&5^s~9wXpC#An=uF#rdZ^6Wp~~1E z+nYXGAu)gjMCyNLEV3DbuQmL;9vp}ZMs*w)T<2Mee#z)gB$r2pg`n>4;#%iJ|5bd- z8X2aS^m%?Jv*jUakly?D7`ZC>`tSqrxAJ59^sMt)@Rl;t)e(tCBiIlzA%Q+CR@Fi-;bC}V^5}yr% zn@=c2I#)L$M-nxVIoD1r*)AtcEVb_X5KnmMre{jXxpZm_$ZGPz#@8FtB7u*b($?|G zjyMFuQPe=}JO(oxGY4#PPD}ct9C3Lv|Mt18x1(`lRZ|VI%y-B4#}3QFA|f|}4Ea1^ zRF?C6*XD;EDpJTeejG4e0HWZ;rfI3w_$4c_cvh~VT4{JE;&e@+!x`j0k@X6j_~P#> zcE9`ZJth!#edkP*r05KZUVH!%sQJ<_G0)*W`lARs5%c~>g8ODe%2#DWVImE*80h&y zb`L_)qF_8SAvA5>t2rO+Hhl+vmEng3L{EoYaz^y_`yU&p!eynU;Q;K_J+xoz&Ta3y z8+o_;IS&lwr`3Akq>Yo!P?=wI<9WaXX7-wcM2#1{+66~nEBsEb(2^;VdQ2cIdrOD7 z^`D<%;LFTOsyJw6F|v3xA(>#|=pu_|E#sOpX|Oz)XPQ&sj`!)R)T{E49(^=sHj;gr zrxK)l```@ZjM((!3S}V8@nQ6H3Uw^Jlmcib|G6m{%AkRED9&f zK4A`|3cakaIDp@UmJTWnyH~IA}nkOK*mrM_}5hU($_&4rQNlr8EwM)sf<7S3yFT&pk11ZP{>tYZeRb8m8a4ACPhWYpA2lgr3WSMM_? zLMq@a@cIpevT09VP$)((<#@+MtMl;XF|4(@Ick1hKRZj{VA=+{@>`*6Cka000e;Xj z;}p_)bMKx>$-KrBbVXNRnIIP^Q=^o%q9D2y;e6yfR( z^MU4C2LqrKx}6p9CBCm!`8=5dz<+W#+Uh#iy?VF2xti66+-xxeMI$?Bv(rBzNfUyA zqz|HbEV;2CI}X<%4gT;>>AF^kx5MvhR-EcsNW$;q5poMPE3pILk^OK}qot;0r!s6g z-Vu`t-m#jZcbcye2Y|0Hwdxi|AK7q|$o1Fkjm9sTSKrjY1LvhuZx{D|**{Q@=f5bl zi_ez0sF|OiciHnYej1U`0__ZPRhBnSwIdieYiK|F_nNjjrt(W`S6ePCOCME~sKtHj zLe#YW;j;{+vh+ioj@U$QESw8JhP-+6M$C04woFBD#in-d)7+X#tH)M6f<8~obDL1@ z2AtWg)wabVgsj8vK zCjJON>pMKzf92V&+zg-BWzUlL&l`pus!WN|8%k7+`}l|#rGxNK_X~aTtE%O7y0^~h z1E&y|5~};yMV6hvN?v#>fmATsOf`#r-?SB@SL%%(?Cv&Tl)PwMr1~PgZB4qByZ@%R z>lM})oV=3>&50bS+7Pn5EBlF*L~?4`A@OX_763VIUPXU~65S^CdGlPgQr@H8-9@-8 z!Pk51Xg9yE1O=^NeQr|l!8PgmCb1!agUO&LaaVKVf1!_^j((+jk?BYiZN7C)s>4mW z%5L0QGUNEgDEem8K9GpP&$c%da}B-c9vOaXJ+k=*E`T#y+LNI4<~n-wH~RPn5R@6t zKoN(!RiMUVKb}~_hU`@A6(?UHVdZFgFtZbnkudF$y>6?Y}ajah&Z-JneFdC*_ZPPft%@ z_Kdm6kT)HkxUXS-Sf%jLQ&V7?W5Sy{Z!)H=_AgcSD;weXmWPDxxWV7(cBdbV>{Li; zQFI?aCW~(!rraI0>MU&9Eb|@}m{#A19BPXjuNDhTkH^7_C~5jEH#<*KIPTLw@=U>8Q)xUcy`#bB=y*RIOwgw-)KCwH&&B78xpbV|f(b(zxwith z3k}7DxG7|Gxi>?~Gf&U~5%vg)Sww~vI|lln0RJ18M!&mc4-R(v#cot~yI#iZFVE8~ z-NI83VOr_8iGB@Csr%SKzZgkG;bl;WUS;`5gG$q!-(O|@plznBbkR!II%|)dfO)%D zrQc&K0Do4ouBad=#Jwc-@oIlkgBARV=mJ84`UHbVC6}Y$x3gQjF6@ZbXW!l?CDkrp zVMAsh4mkXuDL%;&d>F&h_yz_%p!)Ii#>`ga(KyD-)~ncKB!kp`c?%gTEZt*}2HPuA zaG=kTDd>;roKi*!BL!9fI5lRWx!&v+3y94XWL>|0P*j@jKJ>RoU*u9*A!r~BojIDS zF?4skFz6Jtyg9EA4Biaht0XS{wa2$}b`5AKa8`!uRzeRV^3D(HeaV4>uB?5H9DxH; zpjh>1&GyLG$mW(7SLqOB>`1VbpmHSBrwPSQ304pc)PxP3@*9%$8jhva993Y9x)GU? z0i{v6+7Na5Lha|Wp4*JfH(~HWTC(5QhgI(%wO5*rg%2 zD&4}LOI=piJJri|xx${81WNAGNe&>nx%rDt9GGsKgC4gPu7X00-^p}g->{m3VsaRv zw*d#(wDX_(vbmaUrv9=y;znzgqiSiBM@fO5q>r2+qtI4?-Hagruy>OY2nJGGYUa?}?V_PQdv zp!g>EOeLq%#Fa)(nq;Aq^Tzm0bK}uMcD88j%z})^_O+=5wC(k#5=V@QvhQKh7_rl` z{`VL03KF^TL<~}5HKlPP&BWO9r>~|G&V`gMh#;io(EbD^rN;6G);<4_!K+0fhpEv*n#&9x8!}t_hL1Vh)Jfb$5LFtPUBjSFeXLZP?F zt$aVbfiGJ1P{%#*+-iMzsV>L+wj@vmL6{*BG(cgH;Vj}r1g1xGre;$~w6Qiy%wR9+ zFjny|O+-4%O$tazLE@Gya7jkybSPoFa&Xx0Q+|~?&2Hu^`#Si%RDFsS$0kK8lbt&a zX`}5~h)0{LOs=NEwnA71xh*p~aG+oJn>1{TIDM?tcD&_zwDQo<-;t@?q^p@|A^S%~ z+jK=Y^I~>&eE>a-gkTJF{om?SeMt$4@jtzvX&gu)9m+~d8cC~7wiy2besU!Ldu>Td zXw?fXxqX&QY(wofute;M9i%%E6uO!CqNO@08J$_UfUaTLu<4aX$nvbn-JG<&%Li~% z!+LZwZnwyH`~Hmsx1cSmgB`akChTn)WWoE~b7zO{)`xd=`nK^RO{Qk%(wDd8tQi70 zl#o+8;A3<2H}k?jwt_U7BQ|&Y2Dox8UsbUH4kg>DYNXP1K(kksb9j`}e%F$hZ3FDolve&Mv$F}>VV;SEj`y)H>1A!yNJx_Fpao; z!7Yzx|LH_B(Xflf8v#7ZoJ}jkj8Pu5HY&w&ry&K5;oX_*&@=e>9*t zn7@QZ=cc&EVB}-SmE3|8-J^Ydf|58wh1{7XK%}SKs4uZZWY#VwLCRhpj?u1rpzpZm zNa)P<3Jj@%X18QBm0n1Cw3*E#cq_@d_8w8Dyy+HgHY4%x6Qo;qPw1{ZE zWPp}=-TKC}7eumR>D70~{CD2kymUvH(<4BvDdoy^~`u;FRE~AjnP%Ckw5rL|boyKz1PKN(;A>GU; z_3UHr`%@CTW)d$3=c+|lKE!lzli%b&+b=lv9p8ULHC4?~k^UxQZ)F)IjQeLjXx?3{ z=bc8Horwlh5ZJh%r>|9?5fVgyU}k&K;*g=(Y#{NstvJ*OT_;&XVRdhDMOf! zTg8ECc

JUK7YqOs!HPi$8Sx=)F5jGNRV7t6zK3H*I)6quMkxW_bjb6V~RuHi))a z>A4R?#e+Vwny3>966`)HvsdqhfeO!y{fn=5rTPD$k3r0iBnpmpcP+)m#oD>yTaNjW z*AteedBQ?a&kkOH-$rdeD6-}peI7|kBD3q?#ZHO;-8&d1lDWG8gt8^ZUv+gm&Q<;G z&ur=*m~VM)-AMd4E7zPzIUYk9udwbiC_f;8NalB&$2D21MKla2YwvVaMSm*%`l>6& z{dbyzoM${b8`41ub$rLdU(upj;p;Rl5V8?GGu!~b9ia-XCCohZ4!Qap;ZfzH*A!JL zi(}!1M1xu`yrt`&@qO&_SV`?+EUhC~gTYr6wibne#EIuF)a3W2R>K~JLZqbPkmr*C{z4zdH3S%n*ZCws0Qfb76-U8#(NK4dH!(+Nr8?Xa|V8;0G zQ?=DN?2fTv&AfaZ6afrp(kHQgW-A6{-rlV*lSkD1_!n(KP^ho3FJpAFt{Q7e6c&6v zZ%d1Zn<}uYq7M?notKfU!q&}@@bXasM}9zIs+&CQ+wso;rlJ6jR8R#GFuEm(2DALa zQA$pYu31G{ATqCM%q@VKYjCfpOLyx|k~jFb`0yu)wDMg&#_M;_RQJnK{}vw4GbeI0 z2NL;Cgm5jQG=_#OjNh3^hWYakIqP$caXC9$_KAP$8K@AROsKy-@Roi`H411Fsp8>) zoUWzDgbT$A7}I)J@EhTau&b+f-~+MS*nxQ0RJFK12E^J+7M1Bg_&X*Y6b1g{o1gD} zayVXZlxf~~zPj;X76&!F=C3#Y?!06j89dbU={9q3V@sEVjg<$Ts59hr`+j_j0LTb> zC}-uc_)5~yuwFT`Dfs8-cMu4`h{=!Knh^qdPyt?b#&EPA+e#XtZ9jSbC52gBOA8L{ z-s0S9gAOd9q(OumJAc-g?y|$BbMkR=P;Y;&8J?~Np~kp0amshKXmQ2_ybMChG5B!Q zN}DKlSpl)z!=}>Mz-6jZ8VoMM)gAe0^Jkn;NBXZ3I)JE4Ksbf~D6Dc08W!MxTT> z=+y^^_6g>PIjRML?bUT+jjM&TZ1X0y|3R5&fp#ot#6z%aeXw{v5@p}oqQhCmoD-AAUGG}$-I_}Z!2Hd zd0)Ssl}seoAG;&l)Y#crI_>7TFCzJqdO4fV`%4(@hs(&!3sIMw3zN>StF-W9Ou;8l z&Tc(w=)H!S4Z4$b%m;eWu4cBcpT_N0V*b4>-soh==I%FmJDpNmF2#Pw3^lO+?>k&_ zc=Gd7Q|pzP8^p%uo533Vq3mt9W~GW?Nkzl+N%th~H`sEpIdX+CzcGl%#Dob4@X_9$re0I^vg>5$T=#@!M zhDM9#C27Mt>MU?W-8zW0A_=WEhyEfyTMS+}U7 zcIOHEyrppEpOP|V8^*9lx1EM>|3O^FJNvq^ErN^!W||N+Etod#GPlSy#qf6I-}c(z z_^IqZyUqvanfSU*U8x~$~; z&tjR&JF17f&|6`#9VL8NpK=5MQCN;5HpxFucTXiaL%_s`Vk5nAx%U6KJp8G1x(xn) zu}lf@J_LJOnbhnMOzqfF{$*zCQc&8`W=138ZpIB^SW&guzsiogHCy-9{0&t2#QJ)D zLfYpouLWHGir8ynW}Bpx>5U-B6&>%F9G=nSc%8STT)U3ONXu=_%yu{0lg2}1=(3Pp zYO=8MZ9ScOe0I<-<*#w%sTsLzm9W z(P>$}Zrl4+MQnGDgtNTC0x$g8X_@Bz3`)iJZi>SWKHY+@+|5yK`z(I!o|*ljjB! -

-

Nouveau mot de passe

-

Votre mot de passe est provisoire, vous devez le modifier avant de continuer

+ + + +

Nouveau mot de passe

+

Votre mot de passe est provisoire, vous devez le modifier avant de continuer

- - + + - - - 12 à 128 caractères + + + 12 à 128 caractères - @if (errorMessage()) { -

{{ errorMessage() }}

- } + @if (errorMessage()) { + {{ errorMessage() }} + } - + + {{ isLoading() ? 'Modification...' : 'Valider' }} + +
diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.scss b/apps/frontend/src/app/features/auth/change-password/change-password.scss index f44fcb8..48051a5 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.scss +++ b/apps/frontend/src/app/features/auth/change-password/change-password.scss @@ -3,86 +3,42 @@ align-items: center; justify-content: center; min-height: 100vh; - background: #f3f4f6; - font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--color-bg); } -.auth-card { - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 12px; - padding: 2.5rem; +.auth-card-wrapper { 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-logo { + display: block; + height: 48px; + margin: 0 auto 1rem; } -.auth-error { - margin: 0.75rem 0 0; - color: #dc2626; - font-size: 0.85rem; +h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); + text-align: center; +} + +.auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: var(--color-text-muted); + font-size: 0.9rem; + line-height: 1.4; + text-align: center; +} + +ev-alert { + display: block; + margin-top: 0.75rem; +} + +ev-button { + display: block; + margin-top: 1.5rem; } diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts index 63e1872..6fe09d0 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -54,7 +54,7 @@ describe('ChangePassword', () => { fixture.detectChanges(); // rend le bloc @if (errorMessage()) expect(component.errorMessage()).toContain('incorrect'); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('incorrect'); }); @@ -64,7 +64,7 @@ describe('ChangePassword', () => { const button = fixture.nativeElement.querySelector('button[type="submit"]'); expect(button.disabled).toBe(true); - expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull(); }); it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts index 507af14..1acf568 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -2,11 +2,14 @@ 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'; +import { Button } from '../../../shared/components/ui/button/button'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; @Component({ selector: 'app-change-password', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, Button, Card, Alert], templateUrl: './change-password.html', styleUrl: './change-password.scss', }) diff --git a/apps/frontend/src/app/features/auth/login/login.html b/apps/frontend/src/app/features/auth/login/login.html index 0083bd2..89e67fe 100644 --- a/apps/frontend/src/app/features/auth/login/login.html +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -1,36 +1,41 @@
-
-

Connexion

-

Accédez à votre espace EnerVision

+ + + +

Connexion

+

Accédez à votre espace EnerVision

- - + + - - + + - @if (errorMessage()) { -

- {{ errorMessage() }} - @if (retryAfterSeconds(); as seconds) { - (réessayez dans {{ seconds }}s) - } -

- } + @if (errorMessage()) { + + {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } + + } - + + {{ isLoading() ? 'Connexion...' : 'Se connecter' }} + +
diff --git a/apps/frontend/src/app/features/auth/login/login.scss b/apps/frontend/src/app/features/auth/login/login.scss index cc415b8..46cc393 100644 --- a/apps/frontend/src/app/features/auth/login/login.scss +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -3,79 +3,41 @@ align-items: center; justify-content: center; min-height: 100vh; - background: #f3f4f6; - font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--color-bg); } -.auth-card { - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 12px; - padding: 2.5rem; +.auth-card-wrapper { 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; +.auth-logo { + display: block; + height: 48px; + margin: 0 auto 1rem; +} + +h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); + text-align: center; +} + +.auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: var(--color-text-muted); + font-size: 0.9rem; + text-align: center; +} + +ev-alert { + display: block; + margin-top: 0.75rem; +} + +ev-button { + display: block; + margin-top: 1.5rem; } diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts index 3c9bac1..46afce2 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -63,7 +63,7 @@ describe('Login', () => { 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'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.'); }); @@ -80,7 +80,7 @@ describe('Login', () => { fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds) expect(component.retryAfterSeconds()).toBe(30); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('30s'); }); @@ -90,7 +90,7 @@ describe('Login', () => { const button = fixture.nativeElement.querySelector('button[type="submit"]'); expect(button.disabled).toBe(true); - expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull(); }); it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts index 34b9ff2..ea8f62f 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -3,11 +3,14 @@ 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'; @Component({ selector: 'app-login', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, Button, Card, Alert], templateUrl: './login.html', styleUrl: './login.scss', }) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 684b444..533f0e3 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -1,19 +1,22 @@
-
-

Vue d'ensemble

-

Consommation instantanée du parc

+
+ +
+

Vue d'ensemble

+

Consommation instantanée du parc

+
@if (error(); as message) { - + } @if (stats(); as s) {
-
+ Consommation vs capacité {{ s.total_consumption_kw | number: '1.0-1' }} / {{ s.total_capacity_kw | number }} kW -
+ -
+ Charge moyenne du parc {{ s.average_load_percent }} %
-
+ -
+ Sites suivis {{ s.total_sites }} -
+
@@ -50,8 +53,8 @@

Alertes actives

    @for (alert of alerts(); track alert.alert_id) { -
  • - {{ alert.severity }} +
  • + {{ alert.severity }} {{ alert.message }}
  • } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 75976e1..53c5364 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -1,23 +1,22 @@ :host { - --color-good: #2e7d32; - --color-partial: #f9a825; - --color-degraded: #ef6c00; - --color-critical: #c62828; - --color-bg-card: #ffffff; - --color-border: #e5e7eb; - --color-text-muted: #6b7280; - --radius: 10px; - display: block; - font-family: 'Segoe UI', system-ui, sans-serif; - color: #1f2937; + color: var(--color-text); padding: 2rem; max-width: 1100px; margin: 0 auto; } .dashboard__header { + display: flex; + align-items: flex-start; + justify-content: space-between; margin-bottom: 2rem; +} + +.dashboard__brand { + display: flex; + align-items: center; + gap: 0.85rem; h1 { margin: 0; @@ -26,6 +25,11 @@ } } +.dashboard__logo { + height: 40px; + width: auto; +} + .dashboard__subtitle { margin: 0.25rem 0 0; color: var(--color-text-muted); @@ -38,13 +42,8 @@ h2 { } .banner-error { + display: block; margin: 0 0 1.5rem; - padding: 0.75rem 1rem; - border: 1px solid var(--color-critical); - border-left-width: 4px; - border-radius: var(--radius); - background: #fdecea; - color: var(--color-critical); } .overview { @@ -55,14 +54,8 @@ h2 { } .card { - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--radius); padding: 1.25rem; - display: flex; - flex-direction: column; gap: 0.35rem; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } .card--gauge { @@ -84,16 +77,16 @@ h2 { .progress-bar { height: 6px; - background: #e5e7eb; - border-radius: 999px; + background: var(--color-border-light); + border-radius: var(--radius-pill); overflow: hidden; margin-top: 0.25rem; } .progress-bar__fill { height: 100%; - background: #3b82f6; - border-radius: 999px; + background: var(--color-primary); + border-radius: var(--radius-pill); transition: width 0.3s ease; } @@ -115,59 +108,26 @@ h2 { align-items: center; gap: 0.75rem; padding: 0.7rem 1rem; - border-radius: var(--radius); - background: #fef2f2; - border: 1px solid #fecaca; -} - -.alert-item__badge { - font-size: 0.7rem; - font-weight: 700; - text-transform: uppercase; - padding: 0.2rem 0.55rem; - border-radius: 999px; - color: #fff; - background: var(--color-critical); - flex-shrink: 0; -} - -.alert-item--high .alert-item__badge { - background: var(--color-degraded); -} -.alert-item--medium .alert-item__badge { - background: var(--color-partial); -} -.alert-item--low .alert-item__badge { - background: var(--color-good); + border-radius: var(--radius-md); + background: var(--color-danger-bg); + border: 1px solid var(--color-danger-border); } .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; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); font-size: 0.85rem; font-weight: 600; - color: #374151; + color: var(--color-label); cursor: pointer; &:hover { - background: #f3f4f6; + background: var(--color-bg); } } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index c6a6a56..28bc37e 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -9,16 +9,26 @@ import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load 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'; +import { Alert, AlertSeverity } from '../../shared/models/alert.model'; +import { Card } from '../../shared/components/ui/card/card'; +import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; +import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge'; const REFRESH_INTERVAL_MS = 10000; const UNAVAILABLE_MESSAGE = 'Données indisponibles, les valeurs affichées datent du dernier relevé.'; +const TON_PAR_SEVERITE: Record = { + low: 'success', + medium: 'warning', + high: 'danger', + critical: 'danger', +}; + @Component({ selector: 'app-dashboard', standalone: true, - imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart], + imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart, Card, EvAlert, Badge], templateUrl: './dashboard.html', styleUrl: './dashboard.scss', }) @@ -54,6 +64,10 @@ export class Dashboard implements OnInit { }); } + badgeToneForSeverity(severity: AlertSeverity): BadgeTone { + return TON_PAR_SEVERITE[severity]; + } + onLogout(): void { this.auth.logout().subscribe({ next: () => this.router.navigate(['/login']), diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.html b/apps/frontend/src/app/shared/components/ui/alert/alert.html new file mode 100644 index 0000000..6dbc743 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.scss b/apps/frontend/src/app/shared/components/ui/alert/alert.scss new file mode 100644 index 0000000..667f779 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.scss @@ -0,0 +1,27 @@ +:host { + display: block; + margin: 0; + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + border: 1px solid transparent; + font-size: 0.85rem; + line-height: 1.4; +} + +:host.ev-alert--success { + background: var(--color-success-bg); + border-color: var(--color-success); + color: var(--color-success); +} + +:host.ev-alert--warning { + background: var(--color-warning-bg); + border-color: var(--color-warning); + color: #92400e; +} + +:host.ev-alert--danger { + background: var(--color-danger-bg); + border-color: var(--color-danger-border); + color: var(--color-danger); +} diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts b/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts new file mode 100644 index 0000000..cea6bd7 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Alert } from './alert'; + +@Component({ + standalone: true, + imports: [Alert], + template: `C'est fait`, +}) +class AlertHost {} + +describe('Alert', () => { + it('applique la classe danger par défaut', async () => { + await TestBed.configureTestingModule({ imports: [Alert] }).compileComponents(); + const fixture = TestBed.createComponent(Alert); + fixture.detectChanges(); + + expect(fixture.nativeElement.classList).toContain('ev-alert--danger'); + }); + + it('applique la sévérité demandée et projette le contenu', async () => { + await TestBed.configureTestingModule({ imports: [AlertHost] }).compileComponents(); + const fixture = TestBed.createComponent(AlertHost); + fixture.detectChanges(); + + const el = fixture.nativeElement.querySelector('.ev-alert'); + expect(el.classList).toContain('ev-alert--success'); + expect(el.textContent).toContain("C'est fait"); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.ts b/apps/frontend/src/app/shared/components/ui/alert/alert.ts new file mode 100644 index 0000000..45e030f --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.ts @@ -0,0 +1,21 @@ +import { Component, HostBinding, input } from '@angular/core'; + +export type AlertSeverity = 'success' | 'warning' | 'danger'; + +@Component({ + selector: 'ev-alert', + standalone: true, + templateUrl: './alert.html', + styleUrl: './alert.scss', +}) +export class Alert { + severity = input('danger'); + + @HostBinding('class') + get hostClass(): string { + return `ev-alert ev-alert--${this.severity()}`; + } + + @HostBinding('attr.role') + readonly role = 'alert'; +} diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.html b/apps/frontend/src/app/shared/components/ui/badge/badge.html new file mode 100644 index 0000000..9dd0cd5 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.html @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.scss b/apps/frontend/src/app/shared/components/ui/badge/badge.scss new file mode 100644 index 0000000..212281f --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.scss @@ -0,0 +1,27 @@ +.ev-badge { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; + padding: 0.2rem 0.55rem; + border-radius: var(--radius-pill); + color: #fff; + flex-shrink: 0; +} + +.ev-badge--success { + background: var(--color-success); +} + +.ev-badge--warning { + background: var(--color-warning); +} + +.ev-badge--danger { + background: var(--color-danger); +} + +.ev-badge--neutral { + background: var(--color-text-muted); +} diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts b/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts new file mode 100644 index 0000000..09e1a25 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Badge } from './badge'; + +@Component({ + standalone: true, + imports: [Badge], + template: `critique`, +}) +class BadgeHost {} + +describe('Badge', () => { + it('applique le ton neutral par défaut', async () => { + await TestBed.configureTestingModule({ imports: [Badge] }).compileComponents(); + const fixture = TestBed.createComponent(Badge); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.ev-badge').classList).toContain('ev-badge--neutral'); + }); + + it('applique le ton demandé et projette le contenu', async () => { + await TestBed.configureTestingModule({ imports: [BadgeHost] }).compileComponents(); + const fixture = TestBed.createComponent(BadgeHost); + fixture.detectChanges(); + + const el = fixture.nativeElement.querySelector('.ev-badge'); + expect(el.classList).toContain('ev-badge--danger'); + expect(el.textContent).toContain('critique'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.ts b/apps/frontend/src/app/shared/components/ui/badge/badge.ts new file mode 100644 index 0000000..e9cc0ca --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.ts @@ -0,0 +1,13 @@ +import { Component, input } from '@angular/core'; + +export type BadgeTone = 'success' | 'warning' | 'danger' | 'neutral'; + +@Component({ + selector: 'ev-badge', + standalone: true, + templateUrl: './badge.html', + styleUrl: './badge.scss', +}) +export class Badge { + tone = input('neutral'); +} diff --git a/apps/frontend/src/app/shared/components/ui/button/button.html b/apps/frontend/src/app/shared/components/ui/button/button.html new file mode 100644 index 0000000..08ab099 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.html @@ -0,0 +1,3 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/button/button.scss b/apps/frontend/src/app/shared/components/ui/button/button.scss new file mode 100644 index 0000000..0f33524 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.scss @@ -0,0 +1,51 @@ +.ev-button { + width: 100%; + padding: 0.7rem; + border: none; + border-radius: var(--radius-sm); + font-size: 0.95rem; + font-weight: 600; + font-family: var(--font-family); + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.7; + } +} + +.ev-button--primary { + background: var(--color-primary); + color: #fff; + + &:disabled { + background: var(--color-disabled); + } + + &:not(:disabled):hover { + background: var(--color-primary-hover); + } +} + +.ev-button--secondary { + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-label); + + &:not(:disabled):hover { + background: var(--color-bg); + } +} + +.ev-button--danger { + background: var(--color-danger); + color: #fff; + + &:disabled { + background: var(--color-disabled); + } + + &:not(:disabled):hover { + background: #b91c1c; + } +} diff --git a/apps/frontend/src/app/shared/components/ui/button/button.spec.ts b/apps/frontend/src/app/shared/components/ui/button/button.spec.ts new file mode 100644 index 0000000..8362cdc --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.spec.ts @@ -0,0 +1,50 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Button } from './button'; + +@Component({ + standalone: true, + imports: [Button], + template: `Valider`, +}) +class ButtonHost {} + +describe('Button', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [Button] }).compileComponents(); + }); + + it('applique la classe de la variante primary par défaut', () => { + const fixture = TestBed.createComponent(Button); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.classList).toContain('ev-button--primary'); + }); + + it('applique la classe de la variante demandée', () => { + const fixture = TestBed.createComponent(Button); + fixture.componentRef.setInput('variant', 'danger'); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.classList).toContain('ev-button--danger'); + }); + + it('désactive le bouton natif quand disabled est vrai', () => { + const fixture = TestBed.createComponent(Button); + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.disabled).toBe(true); + }); + + it('projette le contenu', async () => { + await TestBed.configureTestingModule({ imports: [ButtonHost] }).compileComponents(); + const fixture = TestBed.createComponent(ButtonHost); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('button').textContent).toContain('Valider'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/button/button.ts b/apps/frontend/src/app/shared/components/ui/button/button.ts new file mode 100644 index 0000000..c9d6b11 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.ts @@ -0,0 +1,15 @@ +import { Component, input } from '@angular/core'; + +export type ButtonVariant = 'primary' | 'secondary' | 'danger'; + +@Component({ + selector: 'ev-button', + standalone: true, + templateUrl: './button.html', + styleUrl: './button.scss', +}) +export class Button { + variant = input('primary'); + type = input<'button' | 'submit'>('button'); + disabled = input(false); +} diff --git a/apps/frontend/src/app/shared/components/ui/card/card.html b/apps/frontend/src/app/shared/components/ui/card/card.html new file mode 100644 index 0000000..6dbc743 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/card/card.scss b/apps/frontend/src/app/shared/components/ui/card/card.scss new file mode 100644 index 0000000..09307fb --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.scss @@ -0,0 +1,10 @@ +:host { + background: var(--color-surface); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + padding: var(--space-5); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + box-sizing: border-box; +} diff --git a/apps/frontend/src/app/shared/components/ui/card/card.spec.ts b/apps/frontend/src/app/shared/components/ui/card/card.spec.ts new file mode 100644 index 0000000..cea71d9 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.spec.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Card } from './card'; + +@Component({ + standalone: true, + imports: [Card], + template: `

    Contenu

    `, +}) +class CardHost {} + +describe('Card', () => { + it('projette son contenu', async () => { + await TestBed.configureTestingModule({ imports: [CardHost] }).compileComponents(); + const fixture = TestBed.createComponent(CardHost); + fixture.detectChanges(); + + const card = fixture.nativeElement.querySelector('ev-card'); + expect(card).toBeTruthy(); + expect(card.textContent).toContain('Contenu'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/card/card.ts b/apps/frontend/src/app/shared/components/ui/card/card.ts new file mode 100644 index 0000000..50c496c --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ev-card', + standalone: true, + templateUrl: './card.html', + styleUrl: './card.scss', +}) +export class Card {} diff --git a/apps/frontend/src/styles.scss b/apps/frontend/src/styles.scss index 90d4ee0..0d45801 100644 --- a/apps/frontend/src/styles.scss +++ b/apps/frontend/src/styles.scss @@ -1 +1,8 @@ -/* You can add global styles to this file, and also import other style files */ +@use 'styles/tokens'; +@use 'styles/forms'; + +body { + margin: 0; + font-family: var(--font-family); + color: var(--color-text); +} diff --git a/apps/frontend/src/styles/_forms.scss b/apps/frontend/src/styles/_forms.scss new file mode 100644 index 0000000..fc35ad4 --- /dev/null +++ b/apps/frontend/src/styles/_forms.scss @@ -0,0 +1,37 @@ +.form-label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--color-label); + margin-bottom: var(--space-1); + margin-top: var(--space-3); +} + +.form-input { + width: 100%; + padding: var(--space-2) 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.95rem; + font-family: var(--font-family); + box-sizing: border-box; + + &:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.15); + } +} + +.form-hint { + display: block; + font-size: 0.75rem; + color: var(--color-disabled); + margin-top: 0.25rem; +} + +.form-error { + margin: var(--space-2) 0 0; + color: var(--color-danger); + font-size: 0.85rem; +} diff --git a/apps/frontend/src/styles/_tokens.scss b/apps/frontend/src/styles/_tokens.scss new file mode 100644 index 0000000..4b85e61 --- /dev/null +++ b/apps/frontend/src/styles/_tokens.scss @@ -0,0 +1,39 @@ +:root { + // Marque (dérivé du logo : vert feuille/éclair, halo) + --color-primary: #16a34a; + --color-primary-hover: #15803d; + --color-primary-light: #dcfce7; + + // Neutres (texte, bordures, fonds) + --color-text: #1f2937; + --color-text-muted: #6b7280; + --color-label: #374151; + --color-border: #d1d5db; + --color-border-light: #e5e7eb; + --color-bg: #f3f4f6; + --color-surface: #ffffff; + --color-disabled: #9ca3af; + + // Sémantique (statuts, alertes) + --color-success: #16a34a; + --color-success-bg: #dcfce7; + --color-warning: #f9a825; + --color-warning-bg: #fef9e7; + --color-danger: #dc2626; + --color-danger-bg: #fef2f2; + --color-danger-border: #fecaca; + + // Typo, rayons, ombre + --font-family: 'Segoe UI', system-ui, sans-serif; + --radius-sm: 8px; + --radius-md: 12px; + --radius-pill: 999px; + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06); + + // Espacements + --space-1: 0.35rem; + --space-2: 0.6rem; + --space-3: 1rem; + --space-4: 1.5rem; + --space-5: 2.5rem; +} diff --git a/docs/architecture/32-design-systeme-frontend.md b/docs/architecture/32-design-systeme-frontend.md new file mode 100644 index 0000000..d2324e9 --- /dev/null +++ b/docs/architecture/32-design-systeme-frontend.md @@ -0,0 +1,76 @@ +# Design système frontend + +Ce que toute nouvelle page ou tout nouveau composant Angular doit réutiliser, plutôt que +redéfinir ses propres couleurs, rayons ou espacements en dur. Contexte : issue +[#91](https://github.com/ineszang/ProjetPiscine_EnerVision/issues/91), née d'une incohérence +visuelle accumulée page après page (aucun jeton partagé n'existait avant ce chantier). + +## Tokens + +Déclarés en CSS custom properties dans `apps/frontend/src/styles/_tokens.scss`, importés une +seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentaire. + +| Variable | Rôle | +|---|---| +| `--color-primary`, `--color-primary-hover`, `--color-primary-light` | Couleur de marque (vert, dérivé du logo), actions principales | +| `--color-text`, `--color-text-muted`, `--color-label` | Hiérarchie de texte (titres, texte secondaire, labels de formulaire) | +| `--color-border`, `--color-border-light` | Bordures d'inputs et de cartes | +| `--color-bg`, `--color-surface` | Fond de page vs fond des cartes/panneaux | +| `--color-disabled` | Éléments désactivés | +| `--color-success` / `-bg`, `--color-warning` / `-bg`, `--color-danger` / `-bg` / `-border` | États sémantiques (alertes, badges) | +| `--font-family` | Police unique de l'application | +| `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) | +| `--shadow-card` | Ombre portée des cartes | +| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) | + +Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`, `.form-error`) +sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles +s'appliquent directement à des `