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 @@
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 @@
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 @@