Skip to content

Commit 46a4269

Browse files
Clément VALENTINclaude
andcommitted
feat: améliorer le flux OAuth et l'UX du Dashboard après consentement
- Ajouter contrainte unique sur usage_point_id (PDL) avec gestion race condition - Améliorer l'affichage des erreurs de consentement (PDL déjà existant) - Ajouter écran de chargement global après retour du consentement - Afficher overlay de synchronisation automatique sur les nouveaux PDL - Supprimer le filtre "Afficher les PDL désactivés" (toujours visibles) - Ajouter bouton Supprimer sur les PDL désactivés (mode compact) - Corriger double exécution useEffect avec useRef guard - Persister états de chargement/erreur via sessionStorage entre remontages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 5c90b92 commit 46a4269

File tree

9 files changed

+712
-298
lines changed

9 files changed

+712
-298
lines changed

apps/api/src/main.py

Lines changed: 81 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -214,48 +214,42 @@ async def health_check() -> HealthCheckResponse:
214214
# Consent callback endpoint
215215
@app.get("/consent", tags=["OAuth"])
216216
async def consent_callback(
217+
request: Request,
217218
code: str = Query(..., description="Authorization code from Enedis"),
218-
state: str = Query(..., description="State parameter containing user_id"),
219+
state: str = Query(None, description="State parameter (ignored - user identified via JWT)"),
219220
usage_point_id: str = Query(None, description="Usage point ID from Enedis"),
220221
db: AsyncSession = Depends(get_db),
221222
) -> RedirectResponse:
222-
"""Handle consent redirect from Enedis and redirect to frontend dashboard"""
223+
"""Handle consent redirect from Enedis and redirect to frontend dashboard.
224+
225+
The user is identified via their JWT token (from cookie or localStorage).
226+
The PDL(s) from Enedis are automatically added to the authenticated user's account.
227+
"""
223228
# Frontend URL from settings
224229
frontend_url = f"{settings.FRONTEND_URL}/dashboard"
225230

226231
logger.info("=" * 60)
227232
logger.debug("[CONSENT] ===== DEBUT CALLBACK ENEDIS =====")
228-
logger.debug(f"[CONSENT] Code reçu: {code[:20]}...")
233+
logger.debug(f"[CONSENT] Code reçu: {code[:20]}..." if code else "[CONSENT] Pas de code")
229234
logger.debug(f"[CONSENT] State reçu: {state}")
230235
logger.debug(f"[CONSENT] Usage Point ID reçu: {usage_point_id}")
231236
logger.debug(f"[CONSENT] Frontend URL: {frontend_url}")
232-
logger.debug(f"[CONSENT] Redirect URI configuré: {settings.ENEDIS_REDIRECT_URI}")
233237
logger.info("=" * 60)
234238

235239
try:
236-
# Parse state - Enedis may return it with format "user_id:usage_point_id" or just "user_id"
237-
# Extract only the user_id part (before the colon if present)
238-
if ":" in state:
239-
user_id = state.split(":")[0].strip()
240-
else:
241-
user_id = state.strip()
242-
243-
# Log for debugging
244-
logger.debug(f"[CONSENT] User ID extrait du state: {user_id}")
240+
# Get the authenticated user from JWT token
241+
from .middleware.auth import get_current_user_optional
245242

246-
# Verify user exists
247-
result = await db.execute(select(User).where(User.id == user_id))
248-
user = result.scalar_one_or_none()
243+
user = await get_current_user_optional(request, db)
249244

250245
if not user:
251-
# Log all users for debugging
252-
all_users = await db.execute(select(User))
253-
user_ids = [u.id for u in all_users.scalars().all()]
254-
logger.error(f"[CONSENT] User not found with ID: {user_id}")
255-
logger.debug(f"[CONSENT] Available users: {user_ids}")
256-
# Redirect to frontend dashboard with error
257-
error_msg = f"user_not_found (looking for: {user_id[:8]}...)"
258-
return RedirectResponse(url=f"{frontend_url}?consent_error={error_msg}")
246+
logger.error("[CONSENT] ✗ Utilisateur non authentifié - redirection vers login")
247+
# Redirect to login with return URL
248+
return_url = f"/oauth/callback?code={code}&usage_point_id={usage_point_id}" if usage_point_id else f"/oauth/callback?code={code}"
249+
return RedirectResponse(url=f"{settings.FRONTEND_URL}/login?redirect={return_url}")
250+
251+
user_id = user.id
252+
logger.info(f"[CONSENT] ✓ Utilisateur authentifié: {user.email} (ID: {user_id})")
259253

260254
# Just create PDL - token will be managed globally via Client Credentials
261255
logger.debug("[CONSENT] ===== TRAITEMENT DU PDL =====")
@@ -283,61 +277,76 @@ async def consent_callback(
283277
logger.warning(f"[CONSENT] ⚠ PDL ignoré (pas d'ID): {up}")
284278
continue
285279

286-
# Check if PDL already exists
280+
# Check if PDL already exists (globally)
287281
from .models import PDL
288282

289-
result = await db.execute(select(PDL).where(PDL.user_id == user_id, PDL.usage_point_id == usage_point_id))
290-
existing_pdl = result.scalar_one_or_none()
283+
result = await db.execute(select(PDL).where(PDL.usage_point_id == usage_point_id))
284+
existing_pdl = result.scalars().first()
291285

292-
if not existing_pdl:
293-
# Create new PDL
286+
if existing_pdl:
287+
# PDL already exists - reject via consent flow
288+
# Admin must use the manual "Add PDL (admin)" button instead
289+
logger.warning(f"[CONSENT] ⚠ PDL {usage_point_id} existe déjà (user_id: {existing_pdl.user_id}) - refusé")
290+
return RedirectResponse(
291+
url=f"{frontend_url}?consent_error=pdl_already_exists&pdl={usage_point_id}"
292+
)
293+
294+
# Create new PDL with race condition handling
295+
try:
294296
new_pdl = PDL(user_id=user_id, usage_point_id=usage_point_id)
295297
db.add(new_pdl)
296-
await db.flush() # Flush to get the ID
298+
await db.flush() # Flush to get the ID - will raise IntegrityError if duplicate
297299
created_count += 1
298300
logger.info(f"[CONSENT] ✓ PDL créé: {usage_point_id}")
299-
300-
# Try to fetch contract info automatically
301-
try:
302-
from .adapters import enedis_adapter
303-
from .routers.enedis import get_valid_token
304-
305-
access_token = await get_valid_token(usage_point_id, user, db)
306-
if access_token:
307-
contract_data = await enedis_adapter.get_contract(usage_point_id, access_token)
308-
309-
if (
310-
contract_data
311-
and "customer" in contract_data
312-
and "usage_points" in contract_data["customer"]
313-
):
314-
usage_points = contract_data["customer"]["usage_points"]
315-
if usage_points and len(usage_points) > 0:
316-
usage_point = usage_points[0]
317-
318-
if "contracts" in usage_point:
319-
contract = usage_point["contracts"]
320-
321-
if "subscribed_power" in contract:
322-
power_str = str(contract["subscribed_power"])
323-
new_pdl.subscribed_power = int(
324-
power_str.replace("kVA", "").replace(" ", "").strip()
325-
)
326-
print(
327-
f"[CONSENT] ✓ Puissance souscrite récupérée: {new_pdl.subscribed_power} kVA"
328-
)
329-
330-
if "offpeak_hours" in contract:
331-
offpeak = contract["offpeak_hours"]
332-
if isinstance(offpeak, str):
333-
new_pdl.offpeak_hours = {"default": offpeak}
334-
elif isinstance(offpeak, dict):
335-
new_pdl.offpeak_hours = offpeak
336-
logger.info(f"[CONSENT] ✓ Heures creuses récupérées: {new_pdl.offpeak_hours}")
337-
except Exception as e:
338-
logger.warning(f"[CONSENT] ⚠ Impossible de récupérer les infos du contrat: {e}")
339-
else:
340-
logger.debug(f"[CONSENT] PDL existe déjà: {usage_point_id}")
301+
except Exception as e:
302+
# Handle race condition: another request created the PDL between our check and insert
303+
if "UNIQUE constraint failed" in str(e) or "duplicate key" in str(e).lower():
304+
logger.warning(f"[CONSENT] ⚠ PDL {usage_point_id} créé par requête concurrente - abandon silencieux")
305+
# Don't redirect with error - let the first request handle it
306+
# Just return empty response to avoid double redirect
307+
from fastapi.responses import Response
308+
return Response(status_code=204) # No Content - browser will ignore
309+
raise # Re-raise other exceptions
310+
311+
# Try to fetch contract info automatically
312+
try:
313+
from .adapters import enedis_adapter
314+
from .routers.enedis import get_valid_token
315+
316+
access_token = await get_valid_token(usage_point_id, user, db)
317+
if access_token:
318+
contract_data = await enedis_adapter.get_contract(usage_point_id, access_token)
319+
320+
if (
321+
contract_data
322+
and "customer" in contract_data
323+
and "usage_points" in contract_data["customer"]
324+
):
325+
usage_points_data = contract_data["customer"]["usage_points"]
326+
if usage_points_data and len(usage_points_data) > 0:
327+
usage_point_data = usage_points_data[0]
328+
329+
if "contracts" in usage_point_data:
330+
contract = usage_point_data["contracts"]
331+
332+
if "subscribed_power" in contract:
333+
power_str = str(contract["subscribed_power"])
334+
new_pdl.subscribed_power = int(
335+
power_str.replace("kVA", "").replace(" ", "").strip()
336+
)
337+
logger.info(
338+
f"[CONSENT] ✓ Puissance souscrite récupérée: {new_pdl.subscribed_power} kVA"
339+
)
340+
341+
if "offpeak_hours" in contract:
342+
offpeak = contract["offpeak_hours"]
343+
if isinstance(offpeak, str):
344+
new_pdl.offpeak_hours = {"default": offpeak}
345+
elif isinstance(offpeak, dict):
346+
new_pdl.offpeak_hours = offpeak
347+
logger.info(f"[CONSENT] ✓ Heures creuses récupérées: {new_pdl.offpeak_hours}")
348+
except Exception as e:
349+
logger.warning(f"[CONSENT] ⚠ Impossible de récupérer les infos du contrat: {e}")
341350

342351
await db.commit()
343352
logger.info("[CONSENT] ✓ Commit effectué en base de données")

apps/api/src/middleware/auth.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,49 @@ async def get_current_user(
8787

8888
logger.error("[AUTH] Authentication failed")
8989
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication credentials")
90+
91+
92+
async def get_current_user_optional(
93+
request: Request,
94+
db: AsyncSession,
95+
) -> Optional[User]:
96+
"""Get current user if authenticated, return None otherwise (no exception raised).
97+
98+
Used for endpoints that can work with or without authentication.
99+
Also checks for token in query params or cookies for OAuth callbacks.
100+
"""
101+
# Try to get token from Authorization header
102+
auth_header = request.headers.get("Authorization")
103+
token = None
104+
105+
if auth_header and auth_header.startswith("Bearer "):
106+
token = auth_header[7:]
107+
108+
# Try to get token from query params (for OAuth callbacks)
109+
if not token:
110+
token = request.query_params.get("access_token")
111+
112+
# Try to get token from cookies
113+
if not token:
114+
token = request.cookies.get("access_token")
115+
116+
if not token:
117+
return None
118+
119+
# Try JWT token
120+
payload = decode_access_token(token)
121+
if payload:
122+
user_id = payload.get("sub")
123+
if user_id:
124+
result = await db.execute(select(User).where(User.id == user_id))
125+
user = result.scalar_one_or_none()
126+
if user and user.is_active:
127+
return user
128+
129+
# Try API key (client_secret)
130+
result = await db.execute(select(User).where(User.client_secret == token))
131+
user = result.scalar_one_or_none()
132+
if user and user.is_active:
133+
return user
134+
135+
return None

apps/api/src/models/pdl.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class PDL(Base, TimestampMixin):
99
__tablename__ = "pdls"
1010

1111
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
12-
usage_point_id: Mapped[str] = mapped_column(String(14), nullable=False, index=True)
12+
usage_point_id: Mapped[str] = mapped_column(String(14), nullable=False, unique=True, index=True)
1313
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
1414
name: Mapped[str | None] = mapped_column(String(100), nullable=True) # Custom name for PDL
1515
display_order: Mapped[int | None] = mapped_column(Integer, nullable=True) # Custom sort order

apps/api/src/routers/oauth.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from datetime import datetime, timedelta, UTC
23
from fastapi import APIRouter, Depends, Query, Path
34
from fastapi.responses import RedirectResponse
@@ -9,22 +10,37 @@
910
from ..middleware import get_current_user
1011
from ..adapters import enedis_adapter
1112
from ..config import settings
13+
from ..services.cache import cache_service
1214

1315
router = APIRouter(prefix="/oauth", tags=["OAuth"])
1416

17+
# TTL for OAuth state mapping (10 minutes)
18+
OAUTH_STATE_TTL = 600
19+
1520

1621
@router.get("/authorize", response_model=APIResponse)
1722
async def get_authorize_url(
1823
current_user: User = Depends(get_current_user),
1924
) -> APIResponse:
2025
"""Get Enedis OAuth authorization URL - Consent is account-level, not per PDL"""
26+
# Generate a random state for CSRF protection
27+
state = str(uuid.uuid4())
28+
29+
# Store the mapping state -> user_id in Redis (TTL: 10 minutes)
30+
if cache_service.redis_client:
31+
await cache_service.redis_client.set(
32+
f"oauth_state:{state}",
33+
current_user.id,
34+
ex=OAUTH_STATE_TTL
35+
)
36+
2137
# Build authorization URL
2238
params = {
2339
"client_id": settings.ENEDIS_CLIENT_ID,
2440
"response_type": "code",
2541
"duration": "P36M", # 36 months
2642
"redirect_uri": settings.ENEDIS_REDIRECT_URI,
27-
"state": current_user.id, # Only user_id in state
43+
"state": state,
2844
}
2945

3046
param_str = "&".join([f"{k}={v}" for k, v in params.items()])
@@ -39,6 +55,20 @@ async def get_authorize_url(
3955
)
4056

4157

58+
@router.get("/verify-state", response_model=APIResponse)
59+
async def verify_oauth_state(
60+
state: str = Query(..., description="OAuth state to verify"),
61+
) -> APIResponse:
62+
"""Verify an OAuth state and return the associated user_id (for debugging)"""
63+
if not cache_service.redis_client:
64+
return APIResponse(success=False, error=ErrorDetail(code="CACHE_ERROR", message="Cache not available"))
65+
66+
user_id = await cache_service.redis_client.get(f"oauth_state:{state}")
67+
if user_id:
68+
return APIResponse(success=True, data={"user_id": user_id.decode() if isinstance(user_id, bytes) else user_id, "state": state})
69+
return APIResponse(success=False, error=ErrorDetail(code="STATE_NOT_FOUND", message="State not found or expired"))
70+
71+
4272
@router.get("/callback", response_model=APIResponse)
4373
async def oauth_callback(
4474
code: str = Query(..., description="Authorization code from Enedis", openapi_examples={"auth_code": {"summary": "Authorization code", "value": "abc123xyz789"}}),

apps/web/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Login from './pages/Login'
77
import Signup from './pages/Signup'
88
import Dashboard from './pages/Dashboard'
99
import OAuthCallback from './pages/OAuthCallback'
10+
import ConsentRedirect from './pages/ConsentRedirect'
1011
import Settings from './pages/Settings'
1112
import VerifyEmail from './pages/VerifyEmail'
1213
import Admin from './pages/Admin'
@@ -324,6 +325,8 @@ function App() {
324325
</ProtectedRoute>
325326
}
326327
/>
328+
{/* Route for Enedis consent redirect - forwards to backend */}
329+
<Route path="/consent" element={<ConsentRedirect />} />
327330
<Route path="/verify-email" element={<VerifyEmail />} />
328331

329332
{/* Error pages */}

0 commit comments

Comments
 (0)