diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index de1235e..1380a84 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -1,9 +1,14 @@ 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.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 +23,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 +50,41 @@ 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. + openapi_original = application.openapi + + def openapi_avec_logo() -> dict[str, object]: + schema = openapi_original() + schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"} + return 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 0000000..d3bdc53 Binary files /dev/null and b/apps/backend/app/static/logo-icon.png differ diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 3f414fa..25fc9ee 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -4,7 +4,11 @@ "title": "EnerVision API", "summary": "Collecte, analyse et restitution de séries temporelles énergétiques.", "description": "\nToutes les routes sont préfixées par `/api/v1`.\n\n**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`.\nLe jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il\nsuffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un\nnouveau jeton d'accès et fait tourner le cookie.\n\n**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du\nprécédent.\n\n**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut\n`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe\nprovisoire avant toute autre action.\n\nLe parcours de session complet est décrit dans\n`docs/architecture/31-contrat-authentification.md`.\n", - "version": "0.1.0" + "version": "0.1.0", + "x-logo": { + "url": "/static/logo-icon.png", + "altText": "EnerVision" + } }, "paths": { "/api/v1/health/live": { diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index 734c9db..25e4760 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -81,3 +81,18 @@ async def test_the_declared_routes_are_actually_reachable(app: FastAPI) -> None: ) def test_the_health_probes_stay_public(app: FastAPI, chemin: str) -> None: assert ("GET", chemin) in ROUTES_PUBLIQUES + + +# Piège : ni les routes `include_in_schema=False` (/docs, /redoc) ni un `Mount` Starlette +# (/static) n'apparaissent dans `app.openapi()["paths"]`. `routes_declarees()` ne les voit +# donc jamais, et elles échapperaient silencieusement au garde-fou ci-dessus. +@pytest.mark.parametrize( + "chemin", + ["/docs", "/redoc", "/static/logo-icon.png"], + ids=["swagger_ui", "redoc", "logo_statique"], +) +async def test_the_documentation_routes_are_public_by_design( + app: FastAPI, client: AsyncClient, chemin: str +) -> None: + response = await client.get(chemin) + assert response.status_code == 200 diff --git a/apps/backend/tests/test_static_assets.py b/apps/backend/tests/test_static_assets.py new file mode 100644 index 0000000..125ecda --- /dev/null +++ b/apps/backend/tests/test_static_assets.py @@ -0,0 +1,13 @@ +# Piège : le logo est committé indépendamment à deux endroits (`app/static/`, servi par +# `/docs`/`/redoc`, et `apps/frontend/public/`, servi au front) faute d'étape de build partagée. +# Sans ce test, une mise à jour d'un seul des deux fichiers dérive silencieusement : rien en CI +# ne le détecte. + +from pathlib import Path + +BACKEND_LOGO = Path(__file__).parent.parent / "app" / "static" / "logo-icon.png" +FRONTEND_LOGO = Path(__file__).parent.parent.parent / "frontend" / "public" / "logo-icon.png" + + +def test_the_backend_logo_stays_in_sync_with_the_frontend_one() -> None: + assert BACKEND_LOGO.read_bytes() == FRONTEND_LOGO.read_bytes() 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/favicon.ico b/apps/frontend/public/favicon.ico index 57614f9..2b78d24 100644 Binary files a/apps/frontend/public/favicon.ico and b/apps/frontend/public/favicon.ico differ diff --git a/apps/frontend/public/logo-icon.png b/apps/frontend/public/logo-icon.png new file mode 100644 index 0000000..d3bdc53 Binary files /dev/null and b/apps/frontend/public/logo-icon.png differ 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 d7b5039..6c3de32 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 @@ -1,31 +1,38 @@
-
-

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 +

- - + + - - - {{ passwordHint }} + + + {{ passwordHint }} - @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..e69de29 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 @@ -1,88 +0,0 @@ -: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; -} 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 0e72843..126e892 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 @@ -63,7 +63,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'); }); @@ -73,7 +73,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 528aea0..06be74d 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,12 +2,16 @@ 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'; +import { Brand } from '../../../shared/components/ui/brand/brand'; import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; @Component({ selector: 'app-change-password', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, Button, Card, Alert, Brand], templateUrl: './change-password.html', styleUrl: './change-password.scss', }) @@ -36,7 +40,9 @@ export class ChangePassword { }, error: () => { this.isLoading.set(false); - this.errorMessage.set(`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`); + 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/login/login.html b/apps/frontend/src/app/features/auth/login/login.html index 3ee100b..cb802ca 100644 --- a/apps/frontend/src/app/features/auth/login/login.html +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -1,38 +1,43 @@
-
-

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 45b28c0..f0ffb17 100644 --- a/apps/frontend/src/app/features/auth/login/login.scss +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -1,85 +1,3 @@ -:host { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - background: #f3f4f6; - font-family: 'Segoe UI', system-ui, sans-serif; -} - -.auth-card { - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 12px; - padding: 2.5rem; - width: 100%; - max-width: 360px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - display: flex; - flex-direction: column; - - h1 { - margin: 0; - font-size: 1.5rem; - font-weight: 700; - color: #1f2937; - } - - .auth-subtitle { - margin: 0.25rem 0 1.5rem; - color: #6b7280; - font-size: 0.9rem; - } - - label { - font-size: 0.85rem; - font-weight: 600; - color: #374151; - margin-bottom: 0.35rem; - margin-top: 1rem; - } - - input { - padding: 0.6rem 0.75rem; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 0.95rem; - - &:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); - } - } - - button { - margin-top: 1.5rem; - padding: 0.7rem; - background: #3b82f6; - color: #fff; - border: none; - border-radius: 8px; - font-size: 0.95rem; - font-weight: 600; - cursor: pointer; - - &:disabled { - background: #9ca3af; - cursor: not-allowed; - } - - &:not(:disabled):hover { - background: #2563eb; - } - } -} - -.auth-error { - margin: 0.75rem 0 0; - color: #dc2626; - font-size: 0.85rem; -} - .auth-link { margin-top: 1rem; font-size: 0.85rem; 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 d100e5e..5c0ac6c 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -79,7 +79,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.'); }); @@ -96,7 +96,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'); }); @@ -114,7 +114,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 f9bd085..22fbe8d 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -3,12 +3,19 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; -import { MESSAGE_LIEN_RESET_INVALIDE, MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; +import { Button } from '../../../shared/components/ui/button/button'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; +import { Brand } from '../../../shared/components/ui/brand/brand'; +import { + MESSAGE_LIEN_RESET_INVALIDE, + MOTIF_LIEN_RESET_INVALIDE, +} from '../../../shared/models/auth-redirect-reason'; @Component({ selector: 'app-login', standalone: true, - imports: [ReactiveFormsModule, RouterLink], + imports: [ReactiveFormsModule, RouterLink, Button, Card, Alert, Brand], 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..6519fc9 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -1,19 +1,24 @@
-
-

Vue d'ensemble

-

Consommation instantanée du parc

+
+
- + Déconnexion
@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 +55,8 @@

Alertes actives