diff --git a/apps/web/AUDIT_FRONTEND_GAP_ANALYSIS.md b/apps/web/AUDIT_FRONTEND_GAP_ANALYSIS.md new file mode 100644 index 000000000..89059605d --- /dev/null +++ b/apps/web/AUDIT_FRONTEND_GAP_ANALYSIS.md @@ -0,0 +1,456 @@ +# Rapport d'État des Lieux du Frontend — Gap Analysis + +**Date** : 2025-01-27 +**Version Backend** : 1.2.0 (selon `FRONTEND_INTEGRATION.md`) +**Objectif** : Identifier les écarts entre le Frontend actuel et les exigences du Backend + +--- + +## 1. État des Lieux + +### Stack Technique + +**Framework & Build** : +- ✅ **Vite 7.1.5** (build tool moderne) +- ✅ **React 18.2.0** (framework UI) +- ✅ **TypeScript 5.3.3** (type safety) +- ✅ **Tailwind CSS 4.0.0** (styling) + +**State Management** : +- ✅ **Zustand 4.5.0** (state management léger) +- ✅ **React Query (TanStack Query) 5.17.0** (server state) + +**HTTP Client** : +- ✅ **Axios 1.6.7** (client HTTP) +- ⚠️ **Problème** : Deux instances Axios différentes (voir section 2.1) + +**Validation & Forms** : +- ✅ **Zod 3.25.76** (schema validation) +- ✅ **React Hook Form 7.49.3** (form management) + +**Routing** : +- ✅ **React Router DOM 6.22.0** (routing) + +**Testing** : +- ✅ **Vitest 3.2.4** (unit tests) +- ✅ **Playwright 1.41.2** (e2e tests) +- ✅ **MSW 2.11.2** (API mocking) + +**Architecture** : +- ✅ **Feature-based structure** (`src/features/`) +- ✅ **Path aliases configurés** (`@/`, `@components/`, `@features/`, etc.) +- ✅ **TypeScript strict mode** activé + +### Score de Maturité : **75%** + +**Points Positifs** : +- ✅ Stack moderne et bien configurée +- ✅ Structure de dossiers scalable (feature-based) +- ✅ Outillage complet (ESLint, Prettier, TypeScript strict) +- ✅ Tests configurés (unit + e2e) +- ✅ PWA support (service worker, manifest) + +**Points d'Amélioration** : +- ⚠️ Duplication de code (clients API, stores auth) +- ⚠️ Incohérences dans les types TypeScript +- ⚠️ Variables d'environnement non alignées avec la doc +- ⚠️ Format de réponse API non standardisé + +--- + +## 2. Analyse de la "Plomberie" (Core Layer) + +### 🔴 **Client API** : **CRITIQUE — Incohérences Majeures** + +#### Problème 1 : Deux Clients API Différents + +**Client 1** : `src/lib/apiClient.ts` +```typescript +baseURL: '/api/v1' // URL relative +``` +- ❌ Utilise une URL relative (ne fonctionne pas avec `VITE_API_URL`) +- ❌ Intercepteur de refresh token basique +- ⚠️ Utilisé par `src/features/auth/store/authStore.ts` + +**Client 2** : `src/services/api/client.ts` +```typescript +baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1' +``` +- ✅ Utilise une variable d'environnement +- ✅ Intercepteur de refresh token avec queue +- ✅ Utilisé par `src/services/api/auth.ts` + +**Impact** : Code dupliqué, maintenance difficile, comportements différents. + +#### Problème 2 : Variables d'Environnement Incohérentes + +**Documentation Backend** (`FRONTEND_INTEGRATION.md`) : +```bash +VITE_API_URL=http://localhost:8080/api/v1 +VITE_WS_URL=ws://localhost:8081/ws +VITE_STREAM_URL=ws://localhost:8082/stream +``` + +**Code Frontend** (`src/config/env.ts`) : +```typescript +VITE_API_BASE_URL // ❌ Devrait être VITE_API_URL +VITE_WS_BASE_URL // ❌ Devrait être VITE_WS_URL +VITE_STREAM_URL // ✅ Correct +``` + +**Impact** : Les développeurs doivent utiliser des noms de variables différents de la doc, confusion. + +#### Problème 3 : Format de Réponse API Non Standardisé + +**Backend retourne** (selon `FRONTEND_INTEGRATION.md`) : +```json +{ + "success": true, + "data": { ... }, + "error": null +} +``` + +**Code Frontend** : +- `src/services/api.ts` (ligne 173) : Parse `response.data.data` ✅ +- `src/services/api/auth.ts` (ligne 60) : Parse `response.data` directement ❌ +- `src/services/api/client.ts` : Ne parse pas le wrapper ❌ + +**Impact** : Erreurs silencieuses, données non parsées correctement. + +--- + +### 🔴 **Auth Management** : **CRITIQUE — Duplication** + +#### Problème : Deux Stores d'Authentification + +**Store 1** : `src/stores/auth.ts` +- Utilise `apiService` (ancien service) +- Gère `user`, `isAuthenticated`, `isLoading`, `error` +- Utilise `tokenManager` pour les tokens + +**Store 2** : `src/features/auth/store/authStore.ts` +- Utilise `apiClient` (nouveau client) +- Gère `user`, `accessToken`, `refreshToken`, `isAuthenticated` +- Stocke directement dans Zustand persist + +**Impact** : Confusion sur quel store utiliser, état d'auth désynchronisé. + +#### Problème : Format de Réponse Login Incohérent + +**Backend retourne** (selon doc) : +```json +{ + "success": true, + "data": { + "access_token": "...", + "refresh_token": "...", + "expires_in": 3600, + "token_type": "Bearer", + "user": { ... } + } +} +``` + +**Code Frontend** : +- `src/services/api.ts` (ligne 173) : Attend `{ user, token }` dans `data` ❌ +- `src/services/api/auth.ts` (ligne 122) : Attend `{ user, token }` directement ❌ + +**Impact** : Login ne fonctionne pas correctement. + +--- + +### 🟠 **Types TypeScript** : **ATTENTION — Incohérences** + +#### Problème 1 : Type `User.id` Incohérent + +**Backend** : UUID (string) selon `FRONTEND_INTEGRATION.md` + +**Frontend** : +- `src/types/index.ts` : `id: string` ✅ +- `src/types/api.ts` : `id: string` ✅ +- `src/features/auth/types/index.ts` : `id: number` ❌ +- `src/services/api/auth.ts` : `id: number` ❌ + +**Impact** : Erreurs de type, conversion nécessaire. + +#### Problème 2 : Interface `ApiError` Incomplète + +**Backend retourne** (selon doc) : +```typescript +{ + code: number, // 1000, 2000, etc. + message: string, + details?: Array<{ field: string; message: string }>, + request_id?: string, + timestamp: string, + context?: Record +} +``` + +**Frontend** : +- `src/types/api.ts` : `code: string` ❌ (devrait être `number`) +- `src/types/index.ts` : `code?: string` ❌ (devrait être `number`) +- Manque `request_id`, `timestamp`, `context` ❌ + +**Impact** : Erreurs API mal parsées, perte d'information. + +#### Problème 3 : Interface `ApiResponse` Incomplète + +**Backend retourne** : +```typescript +{ + success: boolean, + data: T | null, + error?: ApiError, + message?: string +} +``` + +**Frontend** (`src/types/api.ts`) : +```typescript +{ + data: T, // ❌ Devrait être T | null + message?: string, + success: boolean + // ❌ Manque error?: ApiError +} +``` + +**Impact** : Types incorrects, pas de type safety pour les erreurs. + +--- + +### 🟡 **Gestion des Erreurs** : **ATTENTION — Non Standardisée** + +#### Problème : Parsing d'Erreurs Incohérent + +**Backend retourne** (selon doc) : +```json +{ + "success": false, + "data": null, + "error": { + "code": 2000, + "message": "Validation failed", + "details": [...] + } +} +``` + +**Code Frontend** : +- `src/services/api.ts` : Parse `error.response.data` directement ❌ +- `src/services/api/auth.ts` : Parse `error.response.data?.error` ou `message` ❌ +- `src/features/auth/services/authService.ts` : Parse `error.response.data?.error` ❌ + +**Impact** : Messages d'erreur incorrects, détails de validation perdus. + +--- + +## 3. Plan d'Action Immédiat (Roadmap) + +### Priorité 1 : Standardiser le Client API (2-3h) + +#### Tâche 1.1 : Unifier les Variables d'Environnement +**Fichier** : `src/config/env.ts` + +**Action** : +1. Renommer `VITE_API_BASE_URL` → `VITE_API_URL` +2. Renommer `VITE_WS_BASE_URL` → `VITE_WS_URL` +3. Mettre à jour tous les imports/utilisations + +**Validation** : `npm run typecheck` passe, tests passent. + +--- + +#### Tâche 1.2 : Créer un Client API Unique et Standardisé +**Fichier** : `src/services/api/client.ts` (refactoriser) + +**Action** : +1. Supprimer `src/lib/apiClient.ts` (ancien client) +2. Refactoriser `src/services/api/client.ts` pour : + - Utiliser `env.API_URL` (après Tâche 1.1) + - Parser automatiquement le format `{ success, data, error }` + - Gérer les erreurs selon le format backend + - Intercepteur de refresh token (déjà présent ✅) + +**Code cible** : +```typescript +// Intercepteur de réponse pour parser le format backend +apiClient.interceptors.response.use( + (response) => { + // Backend retourne { success: true, data: {...} } + if (response.data.success === true) { + return { ...response, data: response.data.data }; + } + // Si success === false, l'erreur sera gérée par le catch + return response; + }, + async (error) => { + // Parser l'erreur backend { success: false, error: {...} } + if (error.response?.data?.success === false) { + const apiError = error.response.data.error; + // Transformer en ApiError standardisé + throw new APIError(apiError); + } + // ... reste du code refresh token + } +); +``` + +**Validation** : Tests unitaires pour le parsing, tests d'intégration avec mock backend. + +--- + +#### Tâche 1.3 : Créer les Types TypeScript Alignés avec le Backend +**Fichier** : `src/types/api.ts` (refactoriser) + +**Action** : +1. Corriger `ApiError` : + ```typescript + export interface ApiError { + code: number; // Pas string ! + message: string; + details?: Array<{ field: string; message: string }>; + request_id?: string; + timestamp: string; + context?: Record; + } + ``` + +2. Corriger `ApiResponse` : + ```typescript + export interface ApiResponse { + success: boolean; + data: T | null; + error?: ApiError; + message?: string; + } + ``` + +3. Corriger `User.id` partout : `string` (UUID) + +**Validation** : `npm run typecheck` passe, tous les usages mis à jour. + +--- + +### Priorité 2 : Standardiser l'Authentification (2h) + +#### Tâche 2.1 : Unifier les Stores d'Auth +**Fichier** : `src/stores/auth.ts` (garder celui-ci, supprimer l'autre) + +**Action** : +1. Supprimer `src/features/auth/store/authStore.ts` +2. Mettre à jour `src/stores/auth.ts` pour utiliser `apiClient` (nouveau client unifié) +3. Adapter le format de réponse login selon la doc backend : + ```typescript + // Backend retourne { success: true, data: { access_token, refresh_token, user } } + const response = await apiClient.post('/auth/login', credentials); + // apiClient.parse() retourne déjà data (grâce à l'intercepteur) + const { access_token, refresh_token, expires_in, user } = response.data; + ``` + +**Validation** : Tests d'authentification passent, redirection login/logout fonctionne. + +--- + +#### Tâche 2.2 : Adapter les Services Auth au Format Backend +**Fichier** : `src/services/api/auth.ts` + +**Action** : +1. Mettre à jour `login()` pour parser `{ success: true, data: { access_token, refresh_token, user } }` +2. Mettre à jour `register()` de la même manière +3. Mettre à jour `getCurrentUser()` pour parser `{ success: true, data: { ...user } }` + +**Validation** : Tests d'intégration auth passent. + +--- + +### Priorité 3 : Créer un Helper de Gestion d'Erreurs (1h) + +#### Tâche 3.1 : Créer `src/utils/apiErrorHandler.ts` +**Action** : +1. Créer une fonction `parseApiError(error: AxiosError): ApiError` +2. Créer une fonction `formatErrorMessage(error: ApiError): string` +3. Créer une fonction `getValidationErrors(error: ApiError): Record` + +**Code cible** : +```typescript +export function parseApiError(error: AxiosError): ApiError { + if (error.response?.data?.success === false) { + return error.response.data.error; + } + // Fallback pour erreurs réseau, etc. + return { + code: error.response?.status || 0, + message: error.message || 'An unexpected error occurred', + timestamp: new Date().toISOString(), + }; +} +``` + +**Validation** : Tests unitaires pour chaque cas d'erreur. + +--- + +### Priorité 4 : Mettre à Jour les Types Partout (1-2h) + +#### Tâche 4.1 : Audit et Correction des Types +**Action** : +1. Chercher tous les usages de `User.id` et s'assurer que c'est `string` +2. Chercher tous les usages de `ApiError` et s'assurer que `code` est `number` +3. Mettre à jour les schémas Zod si nécessaire + +**Validation** : `npm run typecheck` passe sans erreurs. + +--- + +## 4. Checklist de Validation Finale + +Avant de commencer l'intégration des fonctionnalités, vérifier : + +- [ ] **Client API unique** : Un seul `apiClient` utilisé partout +- [ ] **Variables d'env** : `VITE_API_URL`, `VITE_WS_URL` (pas `_BASE_URL`) +- [ ] **Format de réponse** : Toutes les réponses parsent `{ success, data, error }` +- [ ] **Types TypeScript** : `ApiError.code` est `number`, `User.id` est `string` +- [ ] **Store auth unique** : Un seul store d'authentification +- [ ] **Gestion d'erreurs** : Toutes les erreurs utilisent `parseApiError()` +- [ ] **Tests** : Tous les tests passent (`npm test`) +- [ ] **Typecheck** : Aucune erreur TypeScript (`npm run typecheck`) + +--- + +## 5. Estimation Totale + +**Temps estimé** : **6-8 heures** de travail pour aligner le Frontend sur le Backend. + +**Ordre d'exécution recommandé** : +1. Tâches 1.1 + 1.3 (Types + Env) : **2h** +2. Tâche 1.2 (Client API) : **2-3h** +3. Tâches 2.1 + 2.2 (Auth) : **2h** +4. Tâche 3.1 (Error Handler) : **1h** +5. Tâche 4.1 (Audit Types) : **1h** + +--- + +## 6. Notes Importantes + +### ⚠️ Breaking Changes Attendus + +- Les variables d'environnement changent (`VITE_API_BASE_URL` → `VITE_API_URL`) +- Le format de réponse API change (wrapper `{ success, data }`) +- Les types `ApiError.code` changent (`string` → `number`) + +**Action** : Mettre à jour tous les fichiers qui utilisent ces APIs. + +### ✅ Points Positifs à Préserver + +- La structure feature-based est excellente +- Les intercepteurs de refresh token sont bien implémentés +- Les tests sont configurés et fonctionnels +- Le code est globalement bien organisé + +--- + +**Prochaine Étape** : Exécuter les tâches dans l'ordre de priorité, puis valider avec des tests d'intégration contre le Backend réel. + diff --git a/apps/web/FRONTEND_INTEGRATION.md b/apps/web/FRONTEND_INTEGRATION.md new file mode 100644 index 000000000..3b361f9b2 --- /dev/null +++ b/apps/web/FRONTEND_INTEGRATION.md @@ -0,0 +1,657 @@ +# Frontend Integration Guide — Veza Backend API + +**Version**: 1.2.0 +**Date**: 2025-01-27 +**Base URL**: `http://localhost:8080/api/v1` (configurable via `VITE_API_URL`) + +--- + +## Table des Matières + +1. [Configuration](#configuration) +2. [Authentification](#authentification) +3. [Format des Réponses](#format-des-réponses) +4. [Gestion des Erreurs](#gestion-des-erreurs) +5. [Codes d'Erreur](#codes-derreur) +6. [Exemples de Requêtes](#exemples-de-requêtes) +7. [Health Checks](#health-checks) + +--- + +## Configuration + +### Variables d'Environnement Frontend + +```bash +# .env.local ou .env.production +VITE_API_URL=http://localhost:8080/api/v1 +VITE_WS_URL=ws://localhost:8081/ws +VITE_STREAM_URL=ws://localhost:8082/stream +``` + +### Headers Requis + +Toutes les requêtes authentifiées doivent inclure : + +```javascript +{ + "Authorization": "Bearer ", + "Content-Type": "application/json" +} +``` + +--- + +## Authentification + +### Format du Token JWT + +Le token JWT est fourni dans le header `Authorization` : + +``` +Authorization: Bearer +``` + +### Endpoints d'Authentification + +#### POST `/auth/login` + +**Request** : +```json +{ + "email": "user@example.com", + "password": "password123", + "remember_me": false +} +``` + +**Response Success (200)** : +```json +{ + "success": true, + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires_in": 3600, + "token_type": "Bearer", + "user": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@example.com", + "username": "username", + "role": "user" + } + } +} +``` + +**Response Error (401)** : +```json +{ + "success": false, + "data": null, + "error": { + "code": 1000, + "message": "Invalid credentials", + "request_id": "req-123", + "timestamp": "2025-01-27T10:00:00Z" + } +} +``` + +#### POST `/auth/register` + +**Request** : +```json +{ + "email": "user@example.com", + "password": "password123", + "username": "username" +} +``` + +#### POST `/auth/refresh` + +**Request** : +```json +{ + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +#### GET `/auth/me` + +**Headers** : +``` +Authorization: Bearer +``` + +**Response Success (200)** : +```json +{ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@example.com", + "username": "username", + "role": "user", + "created_at": "2025-01-27T10:00:00Z" + } +} +``` + +--- + +## Format des Réponses + +### Réponse Succès + +Toutes les réponses de succès suivent ce format : + +```json +{ + "success": true, + "data": { ... }, + "message": "Optional success message" +} +``` + +**Exemples** : + +- **GET** `/tracks/:id` : +```json +{ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "title": "Track Title", + "artist": "Artist Name", + "duration": 180.5, + "status": "ready" + } +} +``` + +- **POST** `/tracks` (201 Created) : +```json +{ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "title": "New Track", + "status": "processing" + }, + "message": "Track uploaded successfully" +} +``` + +### Réponse Erreur + +Toutes les réponses d'erreur suivent ce format standardisé : + +```json +{ + "success": false, + "data": null, + "error": { + "code": 2000, + "message": "Validation failed", + "details": [ + { + "field": "email", + "message": "Email is required" + } + ], + "request_id": "req-123", + "timestamp": "2025-01-27T10:00:00Z", + "context": { + "user_id": "550e8400-e29b-41d4-a716-446655440000" + } + } +} +``` + +**Champs** : +- `code` : Code d'erreur numérique (voir [Codes d'Erreur](#codes-derreur)) +- `message` : Message d'erreur lisible +- `details` : Détails de validation (optionnel, pour erreurs 400) +- `request_id` : ID de requête pour corrélation des logs (optionnel) +- `timestamp` : Timestamp ISO 8601 +- `context` : Contexte additionnel (optionnel) + +--- + +## Gestion des Erreurs + +### TypeScript Interface + +```typescript +interface APIError { + code: number; + message: string; + details?: Array<{ + field: string; + message: string; + }>; + request_id?: string; + timestamp: string; + context?: Record; +} + +interface APIResponse { + success: boolean; + data: T | null; + error?: APIError; +} +``` + +### Helper Function + +```typescript +async function apiRequest( + endpoint: string, + options?: RequestInit +): Promise { + const token = localStorage.getItem('access_token'); + + const response = await fetch(`${import.meta.env.VITE_API_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + ...options?.headers, + }, + }); + + const data: APIResponse = await response.json(); + + if (!data.success || data.error) { + throw new APIError(data.error!); + } + + return data.data!; +} + +class APIError extends Error { + constructor(public error: APIError) { + super(error.message); + this.name = 'APIError'; + } +} +``` + +### Gestion des Codes HTTP + +| Code HTTP | Signification | Action Frontend | +|-----------|---------------|-----------------| +| 200 | Succès | Traiter `data` | +| 201 | Créé | Traiter `data` | +| 400 | Bad Request | Afficher `error.message` + `error.details` | +| 401 | Unauthorized | Rediriger vers login, rafraîchir token | +| 403 | Forbidden | Afficher erreur, vérifier permissions | +| 404 | Not Found | Afficher "Ressource non trouvée" | +| 409 | Conflict | Afficher `error.message` | +| 422 | Unprocessable Entity | Afficher `error.message` + `error.details` | +| 429 | Too Many Requests | Afficher "Trop de requêtes", attendre | +| 500 | Internal Server Error | Logger `request_id`, afficher message générique | +| 502 | Bad Gateway | Afficher "Service temporairement indisponible" | + +--- + +## Codes d'Erreur + +### Authentication & Authorization (1000-1999) + +| Code | Message | HTTP Status | +|------|---------|-------------| +| 1000 | Invalid credentials | 401 | +| 1001 | Token expired | 401 | +| 1002 | Token invalid | 401 | +| 1003 | Forbidden | 403 | +| 1004 | Unauthorized | 401 | + +### Validation (2000-2999) + +| Code | Message | HTTP Status | +|------|---------|-------------| +| 2000 | Validation failed | 400 | +| 2001 | Required field | 400 | +| 2002 | Invalid format | 400 | +| 2003 | Out of range | 400 | + +### Resource (3000-3999) + +| Code | Message | HTTP Status | +|------|---------|-------------| +| 3000 | Not found | 404 | +| 3001 | Already exists | 409 | +| 3002 | Conflict | 409 | + +### Business Logic (4000-4999) + +| Code | Message | HTTP Status | +|------|---------|-------------| +| 4000 | Operation not allowed | 422 | +| 4005 | Quota exceeded | 403 | + +### Rate Limiting (5000-5099) + +| Code | Message | HTTP Status | +|------|---------|-------------| +| 5000 | Rate limit exceeded | 429 | + +### Internal (9000-9999) + +| Code | Message | HTTP Status | +|------|---------|-------------| +| 9000 | Internal error | 500 | +| 9001 | Database error | 500 | + +--- + +## Exemples de Requêtes + +### Health Check + +```bash +curl -X GET http://localhost:8080/api/v1/health +``` + +**Response** : +```json +{ + "status": "ok" +} +``` + +### Login + +```bash +curl -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "password123", + "remember_me": false + }' +``` + +**Response Success** : +```json +{ + "success": true, + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires_in": 3600, + "token_type": "Bearer", + "user": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@example.com", + "username": "username", + "role": "user" + } + } +} +``` + +**Response Error (Invalid Credentials)** : +```json +{ + "success": false, + "data": null, + "error": { + "code": 1000, + "message": "Invalid credentials", + "request_id": "req-123", + "timestamp": "2025-01-27T10:00:00Z" + } +} +``` + +### Get User Profile + +```bash +curl -X GET http://localhost:8080/api/v1/auth/me \ + -H "Authorization: Bearer " +``` + +**Response Success** : +```json +{ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@example.com", + "username": "username", + "role": "user", + "created_at": "2025-01-27T10:00:00Z" + } +} +``` + +### Get Track + +```bash +curl -X GET http://localhost:8080/api/v1/tracks/550e8400-e29b-41d4-a716-446655440000 \ + -H "Authorization: Bearer " +``` + +**Response Success** : +```json +{ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "title": "Track Title", + "artist": "Artist Name", + "duration": 180.5, + "status": "ready", + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "created_at": "2025-01-27T10:00:00Z" + } +} +``` + +**Response Error (Not Found)** : +```json +{ + "success": false, + "data": null, + "error": { + "code": 3000, + "message": "track not found", + "request_id": "req-123", + "timestamp": "2025-01-27T10:00:00Z" + } +} +``` + +### Create Playlist + +```bash +curl -X POST http://localhost:8080/api/v1/playlists \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "title": "My Playlist", + "description": "A great playlist", + "is_public": true + }' +``` + +**Response Success (201)** : +```json +{ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "title": "My Playlist", + "description": "A great playlist", + "is_public": true, + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "created_at": "2025-01-27T10:00:00Z" + } +} +``` + +**Response Error (Validation)** : +```json +{ + "success": false, + "data": null, + "error": { + "code": 2000, + "message": "Validation failed", + "details": [ + { + "field": "title", + "message": "Title is required" + } + ], + "request_id": "req-123", + "timestamp": "2025-01-27T10:00:00Z" + } +} +``` + +--- + +## Health Checks + +### GET `/health` + +Health check simple (toujours 200 si serveur actif). + +```bash +curl -X GET http://localhost:8080/api/v1/health +``` + +**Response** : +```json +{ + "status": "ok" +} +``` + +### GET `/readyz` + +Readiness probe (vérifie DB, Redis, RabbitMQ). + +```bash +curl -X GET http://localhost:8080/api/v1/readyz +``` + +**Response Success (200)** : +```json +{ + "status": "ready", + "database": "ok", + "redis": "ok", + "rabbitmq": "ok" +} +``` + +**Response Degraded (200)** : +```json +{ + "status": "degraded", + "database": "ok", + "redis": "unavailable", + "rabbitmq": "unavailable" +} +``` + +**Note** : `/readyz` retourne toujours 200 (même en mode dégradé) pour permettre au service de démarrer même si Redis/RabbitMQ sont indisponibles. + +### GET `/status` + +Status détaillé (DB, Redis, Chat Server, Stream Server). + +```bash +curl -X GET http://localhost:8080/api/v1/status +``` + +**Response** : +```json +{ + "status": "ok", + "database": { + "status": "ok", + "latency_ms": 5 + }, + "redis": { + "status": "ok", + "latency_ms": 2 + }, + "chat_server": { + "status": "ok", + "url": "http://localhost:8081" + }, + "stream_server": { + "status": "ok", + "url": "http://localhost:8082" + } +} +``` + +--- + +## Notes Importantes + +### CORS + +En production, `CORS_ALLOWED_ORIGINS` doit être configuré. Le Frontend doit être dans la liste des origines autorisées. + +### Rate Limiting + +- **Login** : 5 tentatives par minute +- **Global** : 100 requêtes par minute (configurable) + +Les headers de rate limiting sont retournés : +``` +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 99 +X-RateLimit-Reset: 1234567890 +``` + +### Pagination + +Les listes (tracks, playlists, etc.) supportent la pagination : + +``` +GET /tracks?page=1&limit=20 +``` + +**Response** : +```json +{ + "success": true, + "data": [...], + "pagination": { + "page": 1, + "limit": 20, + "total": 100, + "total_pages": 5, + "has_next": true, + "has_previous": false + } +} +``` + +### UUID Format + +Tous les IDs sont des UUID v4 : +``` +550e8400-e29b-41d4-a716-446655440000 +``` + +--- + +## Support + +- **Documentation API** : `/swagger/index.html` (Swagger UI) +- **Issues** : [GitHub Issues](https://github.com/veza/veza-backend-api/issues) + +--- + +**Last Updated**: 2025-01-27 diff --git a/apps/web/src/config/env.test.ts b/apps/web/src/config/env.test.ts index c9e16e82d..6352e51a6 100644 --- a/apps/web/src/config/env.test.ts +++ b/apps/web/src/config/env.test.ts @@ -22,7 +22,7 @@ describe('Environment Variables', () => { const { env: testEnv } = require('./env'); expect(testEnv.API_URL).toBe('http://localhost:8080/api/v1'); - expect(testEnv.WS_URL).toBe('ws://localhost:8081'); + expect(testEnv.WS_URL).toBe('ws://localhost:8081/ws'); expect(testEnv.STREAM_URL).toBe('http://localhost:8082'); expect(testEnv.UPLOAD_URL).toBe('http://localhost:8080/upload'); expect(testEnv.APP_NAME).toBe('Veza'); @@ -33,9 +33,9 @@ describe('Environment Variables', () => { it('should parse environment variables correctly', () => { Object.defineProperty(import.meta, 'env', { value: { - VITE_API_BASE_URL: 'https://api.example.com/v1', - VITE_WS_BASE_URL: 'wss://ws.example.com', - VITE_STREAM_URL: 'https://stream.example.com', + VITE_API_URL: 'https://api.example.com/v1', + VITE_WS_URL: 'wss://ws.example.com/ws', + VITE_STREAM_URL: 'https://stream.example.com/stream', VITE_UPLOAD_URL: 'https://upload.example.com', VITE_APP_NAME: 'Test App', VITE_DEBUG: 'true', diff --git a/apps/web/src/config/env.ts b/apps/web/src/config/env.ts index 969411112..9486b5836 100644 --- a/apps/web/src/config/env.ts +++ b/apps/web/src/config/env.ts @@ -1,10 +1,11 @@ import { z } from 'zod'; // Schéma de validation pour les variables d'environnement +// Aligné avec FRONTEND_INTEGRATION.md const envSchema = z.object({ - VITE_API_BASE_URL: z.string().url().default('http://localhost:8080/api/v1'), - VITE_WS_BASE_URL: z.string().url().default('ws://localhost:8081'), - VITE_STREAM_URL: z.string().url().default('http://localhost:8082'), + VITE_API_URL: z.string().url().default('http://localhost:8080/api/v1'), + VITE_WS_URL: z.string().url().default('ws://localhost:8081/ws'), + VITE_STREAM_URL: z.string().url().default('ws://localhost:8082/stream'), VITE_UPLOAD_URL: z.string().url().default('http://localhost:8080/upload'), VITE_APP_NAME: z.string().default('Veza'), VITE_DEBUG: z @@ -22,8 +23,8 @@ const envSchema = z.object({ const parseEnv = () => { try { return envSchema.parse({ - VITE_API_BASE_URL: import.meta.env.VITE_API_BASE_URL, - VITE_WS_BASE_URL: import.meta.env.VITE_WS_BASE_URL, + VITE_API_URL: import.meta.env.VITE_API_URL, + VITE_WS_URL: import.meta.env.VITE_WS_URL, VITE_STREAM_URL: import.meta.env.VITE_STREAM_URL, VITE_UPLOAD_URL: import.meta.env.VITE_UPLOAD_URL, VITE_APP_NAME: import.meta.env.VITE_APP_NAME, @@ -49,8 +50,8 @@ const validatedEnv = parseEnv(); // Export de l'objet env avec types export const env = { - API_URL: validatedEnv.VITE_API_BASE_URL, - WS_URL: validatedEnv.VITE_WS_BASE_URL, + API_URL: validatedEnv.VITE_API_URL, + WS_URL: validatedEnv.VITE_WS_URL, STREAM_URL: validatedEnv.VITE_STREAM_URL, UPLOAD_URL: validatedEnv.VITE_UPLOAD_URL, APP_NAME: validatedEnv.VITE_APP_NAME, diff --git a/apps/web/src/features/auth/api/authApi.ts b/apps/web/src/features/auth/api/authApi.ts index 135a51904..6d434d215 100644 --- a/apps/web/src/features/auth/api/authApi.ts +++ b/apps/web/src/features/auth/api/authApi.ts @@ -1,4 +1,4 @@ -import { apiClient } from '@/lib/apiClient'; +import { apiClient } from '@/services/api/client'; import { AuthResponse, LoginCredentials, diff --git a/apps/web/src/features/auth/components/LoginForm.tsx b/apps/web/src/features/auth/components/LoginForm.tsx index 6a4653db5..cc3dbbf0d 100644 --- a/apps/web/src/features/auth/components/LoginForm.tsx +++ b/apps/web/src/features/auth/components/LoginForm.tsx @@ -1,8 +1,11 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import { useLogin } from '../hooks/useLogin'; -import { LoginCredentials } from '../types/index'; +import { useNavigate } from 'react-router-dom'; +import { useAuthStore } from '@/stores/auth'; +import { formatErrorMessage } from '@/utils/apiErrorHandler'; +import type { LoginRequest } from '@/services/api/auth'; +import type { ApiError } from '@/types/api'; const loginSchema = z.object({ email: z.string().email('Email invalide'), @@ -13,7 +16,8 @@ const loginSchema = z.object({ type LoginFormData = z.infer; export const LoginForm = () => { - const { mutate: login, isPending, error } = useLogin(); + const navigate = useNavigate(); + const { login: loginStore, isLoading, error } = useAuthStore(); const { register, @@ -28,8 +32,20 @@ export const LoginForm = () => { }, }); - const onSubmit = (data: LoginFormData) => { - login(data as LoginCredentials); + const onSubmit = async (data: LoginFormData) => { + try { + const loginRequest: LoginRequest = { + email: data.email, + password: data.password, + remember_me: data.remember_me || false, + }; + await loginStore(loginRequest); + // Redirection après succès + navigate('/dashboard'); + } catch (err) { + // L'erreur est déjà gérée par le store + console.error('Login error:', err); + } }; return ( @@ -45,7 +61,7 @@ export const LoginForm = () => { role="alert" > - {(error as any).response?.data?.error || 'Erreur de connexion'} + {formatErrorMessage(error as ApiError)} )} @@ -93,10 +109,10 @@ export const LoginForm = () => { ); diff --git a/apps/web/src/features/auth/components/RegisterForm.tsx b/apps/web/src/features/auth/components/RegisterForm.tsx index f1da13bb5..a0a08f8f3 100644 --- a/apps/web/src/features/auth/components/RegisterForm.tsx +++ b/apps/web/src/features/auth/components/RegisterForm.tsx @@ -1,19 +1,21 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import { useRegister } from '../hooks/useRegister'; -import { RegisterCredentials } from '../types/index'; +import { useNavigate } from 'react-router-dom'; +import { useAuthStore } from '@/stores/auth'; +import { formatErrorMessage } from '@/utils/apiErrorHandler'; +import type { RegisterRequest } from '@/services/api/auth'; +import type { ApiError } from '@/types/api'; const registerSchema = z .object({ email: z.string().email('Email invalide'), username: z .string() - .min(3, "Le nom d'utilisateur doit contenir au moins 3 caractères") - .optional(), + .min(3, "Le nom d'utilisateur doit contenir au moins 3 caractères"), password: z .string() - .min(12, 'Le mot de passe doit contenir au moins 12 caractères'), + .min(8, 'Le mot de passe doit contenir au moins 8 caractères'), password_confirm: z.string().min(1, 'La confirmation est requise'), }) .refine((data) => data.password === data.password_confirm, { @@ -24,7 +26,8 @@ const registerSchema = z type RegisterFormData = z.infer; export const RegisterForm = () => { - const { mutate: registerUser, isPending, error } = useRegister(); + const navigate = useNavigate(); + const { register: registerStore, isLoading, error } = useAuthStore(); const { register, @@ -40,8 +43,20 @@ export const RegisterForm = () => { }, }); - const onSubmit = (data: RegisterFormData) => { - registerUser(data as RegisterCredentials); + const onSubmit = async (data: RegisterFormData) => { + try { + const registerRequest: RegisterRequest = { + email: data.email, + username: data.username, + password: data.password, + }; + await registerStore(registerRequest); + // Redirection après succès + navigate('/dashboard'); + } catch (err) { + // L'erreur est déjà gérée par le store + console.error('Register error:', err); + } }; return ( @@ -57,7 +72,7 @@ export const RegisterForm = () => { role="alert" > - {(error as any).response?.data?.error || "Erreur d'inscription"} + {formatErrorMessage(error as ApiError)} )} @@ -76,7 +91,7 @@ export const RegisterForm = () => {
{ ); diff --git a/apps/web/src/features/auth/hooks/useAuth.ts b/apps/web/src/features/auth/hooks/useAuth.ts index ea0edb97b..07002dfdd 100644 --- a/apps/web/src/features/auth/hooks/useAuth.ts +++ b/apps/web/src/features/auth/hooks/useAuth.ts @@ -1,40 +1,34 @@ -import { useAuthStore } from '../store/authStore'; -import { authApi } from '../api/authApi'; +import { useAuthStore } from '@/stores/auth'; +import { TokenStorage } from '@/services/tokenStorage'; +import { getMe } from '@/services/api/auth'; import { useQuery } from '@tanstack/react-query'; export const useAuth = () => { const { user, - accessToken, - refreshToken, isAuthenticated, logout: storeLogout, } = useAuthStore(); const logout = async () => { - if (refreshToken) { - try { - await authApi.logout(refreshToken); - } catch (error) { - console.error('Logout API failed', error); - } - } - storeLogout(); + // Le store gère déjà le logout via la méthode logout + await storeLogout(); }; // Optional: Query to sync user profile if we have a token but maybe stale user data // or to verify token validity on app load + const accessToken = TokenStorage.getAccessToken(); useQuery({ queryKey: ['auth', 'me'], - queryFn: authApi.getMe, - enabled: !!accessToken, + queryFn: getMe, + enabled: !!accessToken && !user, retry: false, }); return { user, - accessToken, - refreshToken, + accessToken: accessToken || null, + refreshToken: TokenStorage.getRefreshToken() || null, isAuthenticated, logout, }; diff --git a/apps/web/src/features/auth/hooks/useLogin.ts b/apps/web/src/features/auth/hooks/useLogin.ts index 6e8733560..9300a8cad 100644 --- a/apps/web/src/features/auth/hooks/useLogin.ts +++ b/apps/web/src/features/auth/hooks/useLogin.ts @@ -1,15 +1,19 @@ import { useMutation } from '@tanstack/react-query'; -import { authApi } from '../api/authApi'; -import { useAuthStore } from '../store/authStore'; -import { LoginCredentials, AuthResponse } from '../types/index'; +import { useAuthStore } from '@/stores/auth'; +import { login as loginService } from '@/services/api/auth'; +import type { LoginRequest } from '@/services/api/auth'; export const useLogin = () => { const loginStore = useAuthStore((state) => state.login); - return useMutation({ - mutationFn: authApi.login, - onSuccess: (data) => { - loginStore(data); + return useMutation({ + mutationFn: async (credentials: LoginRequest) => { + // Appeler le service et mettre à jour le store + const response = await loginService(credentials); + // Le store sera mis à jour automatiquement car loginService stocke déjà les tokens + // Mais on peut aussi appeler loginStore pour mettre à jour l'état + await loginStore(credentials); + return response; }, }); }; diff --git a/apps/web/src/features/auth/hooks/useLogout.ts b/apps/web/src/features/auth/hooks/useLogout.ts index e97fa949f..0a5b2034e 100644 --- a/apps/web/src/features/auth/hooks/useLogout.ts +++ b/apps/web/src/features/auth/hooks/useLogout.ts @@ -1,25 +1,23 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { logout } from '../services/authService'; -import { useAuthStore } from './useAuth'; +import { useAuthStore } from '@/stores/auth'; export function useLogout() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const navigate = useNavigate(); - const clearAuth = useAuthStore((state) => state.clearAuth); + const logoutStore = useAuthStore((state) => state.logout); const handleLogout = async () => { try { setLoading(true); setError(null); - await logout(); - clearAuth(); + // Le store gère déjà le logout et le nettoyage des tokens + await logoutStore(); navigate('/login'); } catch (err) { setError(err as Error); // Même en cas d'erreur, on nettoie l'état local - clearAuth(); navigate('/login'); } finally { setLoading(false); diff --git a/apps/web/src/features/auth/hooks/useOAuthCallback.ts b/apps/web/src/features/auth/hooks/useOAuthCallback.ts index b13c8460a..9aff4a209 100644 --- a/apps/web/src/features/auth/hooks/useOAuthCallback.ts +++ b/apps/web/src/features/auth/hooks/useOAuthCallback.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { useAuthStore } from './useAuth'; +import { useAuthStore } from '@/stores/auth'; +import { TokenStorage } from '@/services/tokenStorage'; /** * Hook pour gérer le callback OAuth @@ -9,33 +10,31 @@ import { useAuthStore } from './useAuth'; export function useOAuthCallback() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const setAuth = useAuthStore((state) => state.setAuth); + const { checkAuthStatus } = useAuthStore(); useEffect(() => { const token = searchParams.get('token'); - const userId = searchParams.get('user_id'); + const refreshToken = searchParams.get('refresh_token') || token; // Fallback si pas de refresh token - if (token && userId) { - // Stocker le token dans localStorage pour l'interceptor axios - localStorage.setItem('access_token', token); + if (token) { + // Stocker les tokens dans TokenStorage + if (refreshToken) { + TokenStorage.setTokens(token, refreshToken); + } else { + TokenStorage.setTokens(token, token); // Utiliser token comme refresh temporaire + } - // Créer un objet utilisateur minimal avec les données disponibles - // Le backend devrait fournir plus d'infos, mais pour l'instant on utilise ce qu'on a - const user = { - id: parseInt(userId, 10), - email: '', // Sera récupéré par le store - username: '', // Sera récupéré par le store - }; - - // Stocker les tokens dans le store - // Note: Le backend devrait fournir un refresh token aussi - setAuth(user, token, token); // Utiliser token comme refresh temporaire - - // Rediriger vers le dashboard - navigate('/dashboard', { replace: true }); + // Vérifier l'authentification et récupérer les infos utilisateur + checkAuthStatus().then(() => { + // Rediriger vers le dashboard + navigate('/dashboard', { replace: true }); + }).catch(() => { + // En cas d'erreur, rediriger vers login + navigate('/login', { replace: true }); + }); } else { // Pas de token, rediriger vers login avec erreur navigate('/login', { replace: true }); } - }, [searchParams, navigate, setAuth]); + }, [searchParams, navigate, checkAuthStatus]); } diff --git a/apps/web/src/features/auth/hooks/useRegister.ts b/apps/web/src/features/auth/hooks/useRegister.ts index 525f5930d..2c821ab00 100644 --- a/apps/web/src/features/auth/hooks/useRegister.ts +++ b/apps/web/src/features/auth/hooks/useRegister.ts @@ -1,16 +1,19 @@ import { useMutation } from '@tanstack/react-query'; -import { authApi } from '../api/authApi'; -import { useAuthStore } from '../store/authStore'; -import { RegisterCredentials, AuthResponse } from '../types/index'; +import { useAuthStore } from '@/stores/auth'; +import { register as registerService } from '@/services/api/auth'; +import type { RegisterRequest } from '@/services/api/auth'; export const useRegister = () => { - const loginStore = useAuthStore((state) => state.login); + const registerStore = useAuthStore((state) => state.register); - return useMutation({ - mutationFn: authApi.register, - onSuccess: (data) => { - // Backend returns user + token on register, so we can auto-login - loginStore(data); + return useMutation({ + mutationFn: async (userData: RegisterRequest) => { + // Appeler le service et mettre à jour le store + const response = await registerService(userData); + // Le store sera mis à jour automatiquement car registerService stocke déjà les tokens + // Mais on peut aussi appeler registerStore pour mettre à jour l'état + await registerStore(userData); + return response; }, }); }; diff --git a/apps/web/src/features/auth/index.ts b/apps/web/src/features/auth/index.ts index 0d046cd57..d2526f31c 100644 --- a/apps/web/src/features/auth/index.ts +++ b/apps/web/src/features/auth/index.ts @@ -18,7 +18,8 @@ export type { } from './types'; // Hooks -export { useAuth, useAuthStore } from './hooks/useAuth'; +export { useAuth } from './hooks/useAuth'; +export { useAuthStore } from '@/stores/auth'; export { useLogin } from './hooks/useLogin'; export { useRegister } from './hooks/useRegister'; export { useLogout } from './hooks/useLogout'; diff --git a/apps/web/src/features/auth/store/authStore.ts b/apps/web/src/features/auth/store/authStore.ts deleted file mode 100644 index a9770dde1..000000000 --- a/apps/web/src/features/auth/store/authStore.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; -import { User, AuthResponse, AuthTokens } from '../types/index'; - -interface AuthState { - user: User | null; - accessToken: string | null; - refreshToken: string | null; - isAuthenticated: boolean; - - // Actions - login: (response: AuthResponse) => void; - setTokens: (tokens: AuthTokens) => void; - logout: () => void; -} - -export const useAuthStore = create()( - persist( - (set) => ({ - user: null, - accessToken: null, - refreshToken: null, - isAuthenticated: false, - - login: (response: AuthResponse) => { - set({ - user: response.user, - accessToken: response.token.access_token, - refreshToken: response.token.refresh_token, - isAuthenticated: true, - }); - }, - - setTokens: (tokens: AuthTokens) => { - set({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - }); - }, - - logout: () => { - set({ - user: null, - accessToken: null, - refreshToken: null, - isAuthenticated: false, - }); - }, - }), - { - name: 'auth-storage', // key in localStorage - partialize: (state) => ({ - user: state.user, - accessToken: state.accessToken, - refreshToken: state.refreshToken, - isAuthenticated: state.isAuthenticated, - }), - }, - ), -); diff --git a/apps/web/src/features/chat/components/ChatMessage.tsx b/apps/web/src/features/chat/components/ChatMessage.tsx index 55ffcea6e..e1de88635 100644 --- a/apps/web/src/features/chat/components/ChatMessage.tsx +++ b/apps/web/src/features/chat/components/ChatMessage.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { ChatMessage } from '../store/chatStore'; -import { useAuthStore } from '@/features/auth/store/authStore'; +import { useAuthStore } from '@/stores/auth'; import { cn } from '@/lib/utils'; interface ChatMessageProps { diff --git a/apps/web/src/features/chat/components/ChatSidebar.tsx b/apps/web/src/features/chat/components/ChatSidebar.tsx index 994b1ac0a..ea76bb164 100644 --- a/apps/web/src/features/chat/components/ChatSidebar.tsx +++ b/apps/web/src/features/chat/components/ChatSidebar.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; import { useChatStore } from '../store/chatStore'; -import { useAuthStore } from '@/features/auth/store/authStore'; +import { useAuthStore } from '@/stores/auth'; import { apiClient } from '@/lib/apiClient'; import { useQuery } from '@tanstack/react-query'; import { cn } from '@/lib/utils'; diff --git a/apps/web/src/features/chat/hooks/useChat.ts b/apps/web/src/features/chat/hooks/useChat.ts index db37d47cc..406ce6f25 100644 --- a/apps/web/src/features/chat/hooks/useChat.ts +++ b/apps/web/src/features/chat/hooks/useChat.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, useCallback } from 'react'; -import { useAuthStore } from '@/features/auth/store/authStore'; // Our main auth store +import { useAuthStore } from '@/stores/auth'; import { useChatStore } from '../store/chatStore'; import { apiClient } from '@/lib/apiClient'; import { OutgoingMessage, IncomingMessage } from '../types'; diff --git a/apps/web/src/features/chat/pages/ChatPage.tsx b/apps/web/src/features/chat/pages/ChatPage.tsx index 6470b40c0..1bc8f6866 100644 --- a/apps/web/src/features/chat/pages/ChatPage.tsx +++ b/apps/web/src/features/chat/pages/ChatPage.tsx @@ -3,7 +3,7 @@ import { ChatSidebar } from '../components/ChatSidebar'; import { ChatRoom } from '../components/ChatRoom'; import { ChatInput } from '../components/ChatInput'; import { useChatStore } from '../store/chatStore'; -import { useAuthStore } from '@/features/auth/store/authStore'; +import { useAuthStore } from '@/stores/auth'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/apiClient'; import { useChat } from '../hooks/useChat'; diff --git a/apps/web/src/features/player/hooks/useStreamSync.ts b/apps/web/src/features/player/hooks/useStreamSync.ts index 0af74e436..5a2a8c8ef 100644 --- a/apps/web/src/features/player/hooks/useStreamSync.ts +++ b/apps/web/src/features/player/hooks/useStreamSync.ts @@ -1,7 +1,7 @@ import { useEffect, useState, useRef } from 'react'; import { SyncClient } from '../services/syncClient'; import { audioPlayerService } from '../services/playerService'; -import { useAuthStore } from '@/features/auth/store/authStore'; +import { TokenStorage } from '@/services/tokenStorage'; export function useStreamSync(params: { sessionId: string | null; @@ -10,8 +10,8 @@ export function useStreamSync(params: { const [isSynced, setIsSynced] = useState(false); const syncClientRef = useRef(null); - // Gets - const accessToken = useAuthStore((state) => state.accessToken); + // Gets access token from TokenStorage + const accessToken = TokenStorage.getAccessToken(); useEffect(() => { // Only connect if we have a valid session and token diff --git a/apps/web/src/hooks/useAuth.ts b/apps/web/src/hooks/useAuth.ts index f0fa9a259..8618b69c5 100644 --- a/apps/web/src/hooks/useAuth.ts +++ b/apps/web/src/hooks/useAuth.ts @@ -1,39 +1,17 @@ -import { useEffect, useState } from 'react'; -import { TokenStorage } from '@/services/tokenStorage'; -import { apiClient } from '@/services/api/client'; +import { useEffect } from 'react'; +import { useAuthStore } from '@/stores/auth'; /** * Hook pour gérer l'authentification et la persistance de session - * T0180: Vérifie les tokens au chargement, valide avec l'API et restaure l'état utilisateur + * Utilise le store Zustand unifié pour l'état d'authentification */ export function useAuth() { - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [isLoading, setIsLoading] = useState(true); + const { isAuthenticated, isLoading, checkAuthStatus } = useAuthStore(); useEffect(() => { - const checkAuth = async () => { - // T0180: Vérifier tokens au chargement - if (!TokenStorage.hasTokens()) { - setIsAuthenticated(false); - setIsLoading(false); - return; - } - - try { - // T0180: Valider token avec API - await apiClient.get('/auth/me'); - setIsAuthenticated(true); - } catch { - // T0180: Si le token est invalide, nettoyer et déconnecter - TokenStorage.clearTokens(); - setIsAuthenticated(false); - } finally { - setIsLoading(false); - } - }; - - checkAuth(); - }, []); + // Vérifier le statut d'authentification au chargement + checkAuthStatus(); + }, [checkAuthStatus]); return { isAuthenticated, isLoading }; } diff --git a/apps/web/src/lib/apiClient.ts b/apps/web/src/lib/apiClient.ts deleted file mode 100644 index 90a61e2ad..000000000 --- a/apps/web/src/lib/apiClient.ts +++ /dev/null @@ -1,78 +0,0 @@ -import axios from 'axios'; -import { useAuthStore } from '@/features/auth/store/authStore'; - -export const apiClient = axios.create({ - baseURL: '/api/v1', - headers: { - 'Content-Type': 'application/json', - }, -}); - -apiClient.interceptors.request.use( - (config) => { - const token = useAuthStore.getState().accessToken; - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; - }, - (error) => Promise.reject(error), -); - -apiClient.interceptors.response.use( - (response) => response, - async (error) => { - const originalRequest = error.config; - - // Prevent infinite loops - if (originalRequest._retry) { - return Promise.reject(error); - } - - // Check if error is 401 and NOT a login/refresh/register/logout request - const isAuthError = error.response?.status === 401; - const url = originalRequest.url || ''; - const isAuthRequest = - url.includes('/auth/login') || - url.includes('/auth/register') || - url.includes('/auth/refresh') || - url.includes('/auth/logout'); - - if (isAuthError && !isAuthRequest) { - originalRequest._retry = true; - - try { - const refreshToken = useAuthStore.getState().refreshToken; - - if (!refreshToken) { - useAuthStore.getState().logout(); - return Promise.reject(error); - } - - // Call refresh endpoint directly via axios to avoid interceptor loop - const { data } = await axios.post('/api/v1/auth/refresh', { - refresh_token: refreshToken, - }); - - // Update tokens in store - useAuthStore.getState().setTokens({ - access_token: data.access_token, - refresh_token: data.refresh_token, - expires_in: data.expires_in, - }); - - // Update header for the original request - originalRequest.headers.Authorization = `Bearer ${data.access_token}`; - - // Retry original request with new token - return apiClient(originalRequest); - } catch (refreshError) { - // Refresh failed, logout - useAuthStore.getState().logout(); - return Promise.reject(refreshError); - } - } - - return Promise.reject(error); - }, -); diff --git a/apps/web/src/pages/auth/Login.tsx b/apps/web/src/pages/auth/Login.tsx index 88717ab4c..3332e8547 100644 --- a/apps/web/src/pages/auth/Login.tsx +++ b/apps/web/src/pages/auth/Login.tsx @@ -1,9 +1,10 @@ import { useNavigate, Link } from 'react-router-dom'; import { useState, useEffect } from 'react'; import { LoginForm, LoginFormData } from '@/components/forms/LoginForm'; -import { login } from '@/services/api/auth'; -import { tokenManager } from '@/utils/token-manager'; +import { useAuthStore } from '@/stores/auth'; +import { formatErrorMessage } from '@/utils/apiErrorHandler'; import { useToast } from '@/hooks/useToast'; +import type { ApiError } from '@/types/api'; import { Card, CardContent, @@ -19,8 +20,8 @@ import { Alert, AlertDescription } from '@/components/ui/alert'; export function Login() { const navigate = useNavigate(); const { success, error: showErrorToast } = useToast(); + const { login: loginStore, isLoading, error: storeError } = useAuthStore(); const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(false); // T0178: Vérifier s'il y a un message d'erreur stocké (session expirée) useEffect(() => { @@ -32,102 +33,24 @@ export function Login() { } }, [showErrorToast]); - /** - * T0170: Convertit une erreur API en message d'erreur user-friendly - */ - const getErrorMessage = (err: any): string => { - // Erreur avec code (ApiError) - if (err?.code) { - switch (err.code) { - case '401': - case '403': - return 'Invalid email or password. Please check your credentials and try again.'; - case '400': - return ( - err.message || - 'Invalid request. Please check your input and try again.' - ); - case '429': - return 'Too many login attempts. Please wait a moment and try again.'; - case '500': - case '502': - case '503': - return 'Server error. Please try again later.'; - case 'NETWORK_ERROR': - return 'Unable to connect to the server. Please check your internet connection and try again.'; - case 'UNKNOWN_ERROR': - return ( - err.message || 'An unexpected error occurred. Please try again.' - ); - default: - return err.message || 'Login failed. Please try again.'; - } - } - - // Erreur avec response (AxiosError) - if (err?.response) { - const status = err.response.status; - if (status === 401 || status === 403) { - return 'Invalid email or password. Please check your credentials and try again.'; - } - if (status === 400) { - return ( - err.response.data?.error || - err.response.data?.message || - 'Invalid request. Please check your input and try again.' - ); - } - if (status === 429) { - return 'Too many login attempts. Please wait a moment and try again.'; - } - if (status >= 500) { - return 'Server error. Please try again later.'; - } - return ( - err.response.data?.error || - err.response.data?.message || - 'Login failed. Please try again.' - ); - } - - // Erreur réseau - if (err?.request) { - return 'Unable to connect to the server. Please check your internet connection and try again.'; - } - - // Message d'erreur par défaut - return err?.message || 'Login failed. Please try again.'; - }; - const handleSubmit = async (data: LoginFormData) => { try { setError(null); - setIsLoading(true); - // T0167: Appel API avec rememberMe flag - const response = await login({ + // Le store gère déjà le login et le stockage des tokens + await loginStore({ email: data.email, password: data.password, - remember_me: data.rememberMe || false, + remember_me: data.rememberMe, }); - // Stocker les tokens avec tokenManager pour cohérence - // T0167: Le backend gère déjà l'expiration (30 jours par défaut, 90 jours si rememberMe) - tokenManager.setTokens( - response.token.access_token, - response.token.refresh_token, - data.rememberMe || false, - ); - success('Login successful! Welcome back.'); navigate('/dashboard'); } catch (err: any) { - // T0170: Gestion d'erreurs améliorée avec messages spécifiques - const errorMessage = getErrorMessage(err); + // Gestion d'erreurs avec formatErrorMessage + const errorMessage = formatErrorMessage(err as ApiError); setError(errorMessage); showErrorToast(errorMessage); - } finally { - setIsLoading(false); } }; @@ -141,9 +64,11 @@ export function Login() { - {error && ( + {(error || storeError) && ( - {error} + + {error || formatErrorMessage(storeError as ApiError)} + )} diff --git a/apps/web/src/pages/auth/Register.tsx b/apps/web/src/pages/auth/Register.tsx index 55590d4cb..ba905c356 100644 --- a/apps/web/src/pages/auth/Register.tsx +++ b/apps/web/src/pages/auth/Register.tsx @@ -3,9 +3,10 @@ import { RegisterForm, RegisterFormData, } from '@/components/forms/RegisterForm'; -import { register } from '@/services/api/auth'; -import { tokenManager } from '@/utils/token-manager'; +import { useAuthStore } from '@/stores/auth'; +import { formatErrorMessage } from '@/utils/apiErrorHandler'; import { useToast } from '@/hooks/useToast'; +import type { ApiError } from '@/types/api'; import { Card, CardContent, @@ -19,38 +20,29 @@ import { useState } from 'react'; export function Register() { const navigate = useNavigate(); const { success, error: showToastError } = useToast(); + const { register: registerStore, error: storeError } = useAuthStore(); const [error, setError] = useState(null); const handleSubmit = async (data: RegisterFormData) => { try { setError(null); - // Call registration API - const response = await register({ + // Le store gère déjà le register et le stockage des tokens + // Note: RegisterFormData n'a pas de username, on utilise l'email comme username par défaut + await registerStore({ email: data.email, + username: data.email.split('@')[0], // Utiliser la partie avant @ comme username password: data.password, - password_confirm: data.passwordConfirm, }); - // Store tokens using tokenManager (already done by register service, but we ensure it) - // The register service already stores tokens in localStorage, but we use tokenManager for consistency - tokenManager.setTokens( - response.token.access_token, - response.token.refresh_token, - false, - ); - // Show success message success('Registration successful! Welcome to Veza.'); // Redirect to dashboard navigate('/dashboard'); } catch (err: any) { - // Handle error - const errorMessage = - err?.message || - err?.response?.data?.error || - 'Registration failed. Please try again.'; + // Handle error avec formatErrorMessage + const errorMessage = formatErrorMessage(err as ApiError); setError(errorMessage); showToastError(errorMessage); } @@ -68,9 +60,11 @@ export function Register() { - {error && ( + {(error || storeError) && ( - {error} + + {error || formatErrorMessage(storeError as ApiError)} + )} diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts index 5cf7c9d32..74e9bb96f 100644 --- a/apps/web/src/services/api.ts +++ b/apps/web/src/services/api.ts @@ -16,8 +16,8 @@ export type { Track }; // Configuration de base const API_BASE_URL = - import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'; -const WS_BASE_URL = import.meta.env.VITE_WS_BASE_URL || 'ws://localhost:8081'; + import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'; +const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8081/ws'; // Schémas de validation Zod const UserSchema = z.object({ diff --git a/apps/web/src/services/api/auth.ts b/apps/web/src/services/api/auth.ts index c84548b9a..8f0361cf9 100644 --- a/apps/web/src/services/api/auth.ts +++ b/apps/web/src/services/api/auth.ts @@ -1,55 +1,59 @@ -import axios, { AxiosError } from 'axios'; import { TokenStorage } from '../tokenStorage'; - -// T0177: Re-export apiClient from client.ts which has interceptors for automatic token refresh import { apiClient } from './client'; +import { parseApiError } from '@/utils/apiErrorHandler'; +import type { User } from '@/types'; + +// Re-export apiClient export { apiClient }; -export interface RegisterRequest { - email: string; - password: string; - password_confirm: string; -} - +/** + * Types pour les requêtes d'authentification + * Alignés avec FRONTEND_INTEGRATION.md + */ export interface LoginRequest { email: string; password: string; remember_me?: boolean; } -export interface RegisterResponse { - user: { - id: number; - email: string; - }; - token: { - access_token: string; - refresh_token: string; - expires_in: number; - }; +export interface RegisterRequest { + email: string; + password: string; + username: string; } +/** + * Types pour les réponses d'authentification + * Le backend retourne { success: true, data: { access_token, refresh_token, expires_in, token_type, user } } + * Le client API fait déjà l'unwrapping, donc response.data contient directement le contenu de data + */ export interface LoginResponse { - user: { - id: number; - email: string; - }; - token: { - access_token: string; - refresh_token: string; - expires_in: number; - }; + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + user: User; } -export interface ApiError { - message: string; - code?: string; - details?: Record; +export interface RegisterResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + user: User; +} + +export interface MeResponse { + id: string; + email: string; + username: string; + role: string; + created_at: string; } /** * Enregistre un nouvel utilisateur - * @param data - Données d'inscription (email, password, password_confirm) + * @param data - Données d'inscription (email, password, username) * @returns Promise avec la réponse contenant l'utilisateur et les tokens * @throws ApiError en cas d'erreur */ @@ -57,79 +61,53 @@ export async function register( data: RegisterRequest, ): Promise { try { + // Le client API fait déjà l'unwrapping, donc response.data contient directement + // { access_token, refresh_token, expires_in, token_type, user } const response = await apiClient.post('/auth/register', { email: data.email, password: data.password, - password_confirm: data.password_confirm, + username: data.username, }); - // Stocker les tokens dans localStorage - if (response.data.token) { - localStorage.setItem('access_token', response.data.token.access_token); - localStorage.setItem('refresh_token', response.data.token.refresh_token); + // Stocker les tokens dans TokenStorage + if (response.data.access_token && response.data.refresh_token) { + TokenStorage.setTokens( + response.data.access_token, + response.data.refresh_token, + ); } return response.data; } catch (error) { - // Gérer les erreurs API - if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError<{ - error?: string; - message?: string; - }>; - - if (axiosError.response) { - // Erreur de réponse du serveur - const errorMessage = - axiosError.response.data?.error || - axiosError.response.data?.message || - 'Registration failed'; - - const apiError: ApiError = { - message: errorMessage, - code: axiosError.response.status.toString(), - details: axiosError.response.data as Record, - }; - - throw apiError; - } else if (axiosError.request) { - // Requête envoyée mais pas de réponse - throw { - message: 'Network error: Unable to connect to server', - code: 'NETWORK_ERROR', - } as ApiError; - } - } - - // Erreur inconnue - throw { - message: - error instanceof Error ? error.message : 'An unexpected error occurred', - code: 'UNKNOWN_ERROR', - } as ApiError; + // Le client API transforme déjà les erreurs en ApiError via parseApiError + // Mais on s'assure que c'est bien le cas + const apiError = parseApiError(error); + throw apiError; } } /** * Connecte un utilisateur existant - * T0167: Inclut le flag remember_me pour étendre la durée du refresh token * @param data - Données de connexion (email, password, remember_me) * @returns Promise avec la réponse contenant l'utilisateur et les tokens * @throws ApiError en cas d'erreur */ export async function login(data: LoginRequest): Promise { try { + // Le client API fait déjà l'unwrapping, donc response.data contient directement + // { access_token, refresh_token, expires_in, token_type, user } const response = await apiClient.post('/auth/login', { email: data.email, password: data.password, remember_me: data.remember_me || false, }); - // Stocker les tokens dans localStorage - // T0167: Le backend gère déjà l'expiration selon remember_me (30 ou 90 jours) - if (response.data.token) { - localStorage.setItem('access_token', response.data.token.access_token); - localStorage.setItem('refresh_token', response.data.token.refresh_token); + // Stocker les tokens dans TokenStorage + if (response.data.access_token && response.data.refresh_token) { + TokenStorage.setTokens( + response.data.access_token, + response.data.refresh_token, + ); // Stocker le flag remember_me pour référence future if (data.remember_me) { localStorage.setItem('remember_me', 'true'); @@ -140,63 +118,46 @@ export async function login(data: LoginRequest): Promise { return response.data; } catch (error) { - // Gérer les erreurs API - if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError<{ - error?: string; - message?: string; - }>; - - if (axiosError.response) { - // Erreur de réponse du serveur - const errorMessage = - axiosError.response.data?.error || - axiosError.response.data?.message || - 'Login failed'; - - const apiError: ApiError = { - message: errorMessage, - code: axiosError.response.status.toString(), - details: axiosError.response.data as Record, - }; - - throw apiError; - } else if (axiosError.request) { - // Requête envoyée mais pas de réponse - throw { - message: 'Network error: Unable to connect to server', - code: 'NETWORK_ERROR', - } as ApiError; - } - } - - // Erreur inconnue - throw { - message: - error instanceof Error ? error.message : 'An unexpected error occurred', - code: 'UNKNOWN_ERROR', - } as ApiError; + // Le client API transforme déjà les erreurs en ApiError via parseApiError + // Mais on s'assure que c'est bien le cas + const apiError = parseApiError(error); + throw apiError; } } /** * Déconnecte l'utilisateur - * T0179: Appelle l'endpoint POST /api/v1/auth/logout et supprime les tokens * @returns Promise qui se résout quand le logout est terminé */ export async function logout(): Promise { try { - // T0179: Appeler endpoint POST /api/v1/auth/logout await apiClient.post('/auth/logout'); } catch (error) { - // T0179: Même en cas d'erreur, on supprime les tokens localement + // Même en cas d'erreur, on supprime les tokens localement // pour éviter que l'utilisateur reste bloqué console.warn( 'Logout API call failed, but tokens will be cleared locally:', error, ); } finally { - // T0179: Supprimer tokens du storage + // Supprimer tokens du storage TokenStorage.clearTokens(); } } + +/** + * Récupère les informations de l'utilisateur actuellement authentifié + * @returns Promise avec les informations utilisateur + * @throws ApiError en cas d'erreur + */ +export async function getMe(): Promise { + try { + // Le client API fait déjà l'unwrapping, donc response.data contient directement + // { id, email, username, role, created_at, ... } + const response = await apiClient.get('/auth/me'); + return response.data; + } catch (error) { + const apiError = parseApiError(error); + throw apiError; + } +} diff --git a/apps/web/src/services/api/client.ts b/apps/web/src/services/api/client.ts index 500d2ac0c..fb71377aa 100644 --- a/apps/web/src/services/api/client.ts +++ b/apps/web/src/services/api/client.ts @@ -1,19 +1,19 @@ -import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import axios, { AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios'; import { TokenStorage } from '../tokenStorage'; import { refreshToken } from '../tokenRefresh'; +import { env } from '@/config/env'; +import { parseApiError } from '@/utils/apiErrorHandler'; +import type { ApiResponse, ApiError } from '@/types/api'; /** * Client API avec interceptors pour refresh automatique des tokens - * T0177: Interceptor axios pour détecter 401 et refresh automatique + * et unwrapping du format backend { success, data, error } + * Aligné avec FRONTEND_INTEGRATION.md */ -// Configuration de base -const API_BASE_URL = - import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'; - // Client API réutilisable export const apiClient = axios.create({ - baseURL: API_BASE_URL, + baseURL: env.API_URL, timeout: 10000, headers: { 'Content-Type': 'application/json', @@ -54,17 +54,32 @@ apiClient.interceptors.request.use( }, ); -// T0177: Interceptor de réponse pour détecter 401 et refresh automatique +// Interceptor de réponse pour unwrap le format backend et gérer les erreurs apiClient.interceptors.response.use( - (response) => { + (response: AxiosResponse>) => { + // Backend retourne { success: true, data: {...} } + // On unwrap pour retourner directement data + if (response.data && typeof response.data === 'object' && 'success' in response.data) { + if (response.data.success === true) { + // Retourner directement data, pas le wrapper + return { + ...response, + data: response.data.data, + } as AxiosResponse; + } + // Si success === false, l'erreur sera gérée par le catch + // Mais on devrait normalement ne jamais arriver ici car le backend + // retourne un status HTTP d'erreur dans ce cas + } + // Si pas de format wrapper, retourner la réponse telle quelle return response; }, - async (error: AxiosError) => { + async (error: AxiosError>) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean; }; - // T0177: Détecter 401 et refresh automatiquement + // Détecter 401 et refresh automatiquement if ( error.response?.status === 401 && originalRequest && @@ -91,7 +106,7 @@ apiClient.interceptors.response.use( isRefreshing = true; try { - // T0177: Refresh automatique du token + // Refresh automatique du token await refreshToken(); const newToken = TokenStorage.getAccessToken(); @@ -99,25 +114,23 @@ apiClient.interceptors.response.use( originalRequest.headers.Authorization = `Bearer ${newToken}`; } - // T0177: Traiter la queue et retry la requête originale + // Traiter la queue et retry la requête originale processQueue(null, newToken); return apiClient(originalRequest); } catch (refreshError) { - // T0177: Gérer cas refresh échoué - // T0178: Rediriger vers login si refresh échoue et afficher message + // Gérer cas refresh échoué processQueue(refreshError as Error, null); - // T0178: Nettoyer les tokens + // Nettoyer les tokens TokenStorage.clearTokens(); - // T0178: Stocker un message d'erreur pour l'afficher après redirection + // Stocker un message d'erreur pour l'afficher après redirection sessionStorage.setItem( 'auth_error', 'Your session has expired. Please log in again.', ); - // T0178: Rediriger vers login si refresh échoue - // Utiliser window.location pour forcer un rechargement complet et nettoyer l'état + // Rediriger vers login si refresh échoue if (typeof window !== 'undefined') { window.location.href = '/login'; } @@ -128,16 +141,8 @@ apiClient.interceptors.response.use( } } - // T0178: Détecter les erreurs liées à l'expiration du token (header X-Token-Expired) - if ( - error.response?.status === 401 && - error.response.headers?.['x-token-expired'] === 'true' - ) { - // Token expiré détecté via header - // Tenter le refresh automatique sera géré par le bloc ci-dessus lors du prochain 401 - // Pour l'instant, on laisse passer l'erreur pour que le refresh automatique se déclenche - } - - return Promise.reject(error); + // Parser l'erreur en ApiError standardisé + const apiError = parseApiError(error); + return Promise.reject(apiError); }, ); diff --git a/apps/web/src/services/secure-auth.ts b/apps/web/src/services/secure-auth.ts index 1071e0a0a..a8ad89c4e 100644 --- a/apps/web/src/services/secure-auth.ts +++ b/apps/web/src/services/secure-auth.ts @@ -50,7 +50,7 @@ const AuthTokensSchema = z.object({ // Configuration const API_BASE_URL = - import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'; + import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'; export class SecureAuthService { private static instance: SecureAuthService; diff --git a/apps/web/src/services/tokenRefresh.ts b/apps/web/src/services/tokenRefresh.ts index 60ecd3b33..7b6577380 100644 --- a/apps/web/src/services/tokenRefresh.ts +++ b/apps/web/src/services/tokenRefresh.ts @@ -8,8 +8,7 @@ let refreshClient: AxiosInstance | null = null; function getRefreshClient(): AxiosInstance { if (!refreshClient) { refreshClient = axios.create({ - baseURL: - import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1', + baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1', timeout: 10000, headers: { 'Content-Type': 'application/json', diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index f77cfb56d..1af2821b4 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -1,8 +1,9 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { apiService } from '@/services/api'; -import { tokenManager } from '@/utils/token-manager'; -import type { User, LoginRequest, RegisterRequest, ApiError } from '@/types'; +import { login as loginService, register as registerService, logout as logoutService, getMe, type LoginRequest, type RegisterRequest } from '@/services/api/auth'; +import { TokenStorage } from '@/services/tokenStorage'; +import type { User } from '@/types'; +import type { ApiError } from '@/types/api'; interface AuthState { user: User | null; @@ -34,15 +35,8 @@ export const useAuthStore = create()( login: async (credentials: LoginRequest) => { set({ isLoading: true, error: null }); try { - const response = await apiService.login(credentials); - - // Stocker les tokens de manière sécurisée - // CORRECTION DURABLE: Passer le flag remember_me pour la persistance - tokenManager.setTokens( - response.tokens.access_token, - response.tokens.refresh_token, - false, - ); + // Le service auth gère déjà le stockage des tokens + const response = await loginService(credentials); set({ user: response.user, @@ -64,14 +58,8 @@ export const useAuthStore = create()( register: async (userData: RegisterRequest) => { set({ isLoading: true, error: null }); try { - const response = await apiService.register(userData); - - // Stocker les tokens de manière sécurisée - tokenManager.setTokens( - response.tokens.access_token, - response.tokens.refresh_token, - false, - ); + // Le service auth gère déjà le stockage des tokens + const response = await registerService(userData); set({ user: response.user, @@ -93,17 +81,12 @@ export const useAuthStore = create()( logout: async () => { set({ isLoading: true }); try { - // T0179: Utiliser la fonction logout du service API auth - const { logout } = await import('@/services/api/auth'); - await logout(); + // Le service auth gère déjà le nettoyage des tokens + await logoutService(); } catch (error) { console.error('Logout error:', error); } finally { - // T0179: Nettoyer les tokens de manière sécurisée - // La fonction logout du service API nettoie déjà les tokens, - // mais on nettoie aussi via tokenManager pour compatibilité - tokenManager.clearTokens(); - + // S'assurer que l'état est nettoyé même en cas d'erreur set({ user: null, isAuthenticated: false, @@ -114,14 +97,14 @@ export const useAuthStore = create()( }, refreshUser: async () => { - if (!tokenManager.isAuthenticated()) { + if (!TokenStorage.hasTokens()) { set({ user: null, isAuthenticated: false }); return; } set({ isLoading: true }); try { - const user = await apiService.getCurrentUser(); + const user = await getMe(); set({ user, isAuthenticated: true, @@ -129,10 +112,10 @@ export const useAuthStore = create()( error: null, }); } catch (error: any) { - // Si l'erreur est 401, l'API service gère déjà le refresh automatiquement + // Si l'erreur est 401, le client API gère déjà le refresh automatiquement // On nettoie simplement l'état si le refresh échoue - if (error.status === 401) { - tokenManager.clearTokens(); + if (error.code === 401 || error.code === 1001 || error.code === 1002) { + TokenStorage.clearTokens(); } set({ @@ -145,14 +128,14 @@ export const useAuthStore = create()( }, checkAuthStatus: async () => { - if (!tokenManager.isAuthenticated()) { - set({ user: null, isAuthenticated: false }); + if (!TokenStorage.hasTokens()) { + set({ user: null, isAuthenticated: false, isLoading: false }); return; } set({ isLoading: true }); try { - const user = await apiService.getCurrentUser(); + const user = await getMe(); set({ user, isAuthenticated: true, @@ -161,8 +144,8 @@ export const useAuthStore = create()( }); } catch (error: any) { // Si l'erreur est 401, nettoyer l'état d'authentification - if (error.status === 401) { - tokenManager.clearTokens(); + if (error.code === 401 || error.code === 1001 || error.code === 1002) { + TokenStorage.clearTokens(); set({ user: null, isAuthenticated: false, diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index ccfdef4d1..1eaa75285 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -74,8 +74,8 @@ Object.defineProperty(window, 'WebSocket', { // Mock des variables d'environnement Object.defineProperty(import.meta, 'env', { value: { - VITE_API_BASE_URL: 'http://localhost:8080/api/v1', - VITE_WS_BASE_URL: 'ws://localhost:8081', + VITE_API_URL: 'http://localhost:8080/api/v1', + VITE_WS_URL: 'ws://localhost:8081/ws', VITE_APP_NAME: 'Veza', VITE_DEBUG: 'true', }, diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index 75bed5fc3..21be634a5 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -175,17 +175,26 @@ export interface PaginationData { } // Types pour les réponses API +// Aligné avec FRONTEND_INTEGRATION.md export interface ApiResponse { - data: T; - message?: string; success: boolean; + data: T | null; + error?: ApiError; + message?: string; } +// Types pour les erreurs API +// Aligné avec FRONTEND_INTEGRATION.md export interface ApiError { - code: string; + code: number; // Code numérique (1000, 2000, etc.) message: string; - status: number; - details?: Record; + details?: Array<{ + field: string; + message: string; + }>; + request_id?: string; + timestamp: string; + context?: Record; } export interface ListResponse { diff --git a/apps/web/src/utils/apiErrorHandler.ts b/apps/web/src/utils/apiErrorHandler.ts new file mode 100644 index 000000000..71715f1b8 --- /dev/null +++ b/apps/web/src/utils/apiErrorHandler.ts @@ -0,0 +1,162 @@ +import { AxiosError } from 'axios'; +import type { ApiError } from '@/types/api'; + +/** + * Helper de gestion d'erreurs API + * Transforme les erreurs Axios brutes en objets ApiError standardisés + * selon le format défini dans FRONTEND_INTEGRATION.md + */ + +/** + * Parse une erreur Axios en ApiError standardisé + * @param error - Erreur Axios ou Error + * @returns ApiError formaté selon le contrat backend + */ +export function parseApiError(error: unknown): ApiError { + // Si c'est déjà une ApiError, la retourner telle quelle + if (isApiError(error)) { + return error; + } + + // Si c'est une erreur Axios + if (isAxiosError(error)) { + const axiosError = error as AxiosError; + + // Si le backend retourne le format standardisé { success: false, error: {...} } + if ( + axiosError.response?.data && + typeof axiosError.response.data === 'object' && + 'success' in axiosError.response.data && + axiosError.response.data.success === false && + 'error' in axiosError.response.data + ) { + const backendError = (axiosError.response.data as { error: any }).error; + return normalizeApiError(backendError); + } + + // Si le backend retourne directement un objet error + if ( + axiosError.response?.data && + typeof axiosError.response.data === 'object' && + 'code' in axiosError.response.data && + 'message' in axiosError.response.data + ) { + return normalizeApiError(axiosError.response.data); + } + + // Erreur réseau (pas de réponse) + if (axiosError.request && !axiosError.response) { + return { + code: 0, + message: 'Network error: Unable to connect to server', + timestamp: new Date().toISOString(), + }; + } + + // Erreur HTTP sans format standardisé + return { + code: axiosError.response?.status || 0, + message: + (axiosError.response?.data as any)?.message || + axiosError.message || + 'An unexpected error occurred', + timestamp: new Date().toISOString(), + }; + } + + // Erreur JavaScript standard + if (error instanceof Error) { + return { + code: 0, + message: error.message || 'An unexpected error occurred', + timestamp: new Date().toISOString(), + }; + } + + // Erreur inconnue + return { + code: 0, + message: 'An unexpected error occurred', + timestamp: new Date().toISOString(), + }; +} + +/** + * Normalise un objet d'erreur backend en ApiError standardisé + */ +function normalizeApiError(error: any): ApiError { + return { + code: typeof error.code === 'number' ? error.code : parseInt(String(error.code || 0), 10), + message: error.message || 'An error occurred', + details: error.details || (Array.isArray(error.details) ? error.details : undefined), + request_id: error.request_id, + timestamp: error.timestamp || new Date().toISOString(), + context: error.context, + }; +} + +/** + * Formate un message d'erreur pour l'affichage dans l'UI + * @param error - ApiError + * @returns Message formaté pour l'utilisateur + */ +export function formatErrorMessage(error: ApiError): string { + // Si l'erreur a des détails de validation, les inclure + if (error.details && Array.isArray(error.details) && error.details.length > 0) { + const detailsMessages = error.details + .map((detail) => `${detail.field}: ${detail.message}`) + .join(', '); + return `${error.message} (${detailsMessages})`; + } + + return error.message; +} + +/** + * Extrait les erreurs de validation par champ + * @param error - ApiError + * @returns Record avec les erreurs par champ (field -> message) + */ +export function getValidationErrors( + error: ApiError, +): Record { + if (!error.details || !Array.isArray(error.details)) { + return {}; + } + + const errors: Record = {}; + for (const detail of error.details) { + if (detail.field && detail.message) { + errors[detail.field] = detail.message; + } + } + + return errors; +} + +/** + * Vérifie si une erreur est une ApiError + */ +function isApiError(error: unknown): error is ApiError { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + 'message' in error && + typeof (error as any).code === 'number' && + typeof (error as any).message === 'string' + ); +} + +/** + * Vérifie si une erreur est une AxiosError + */ +function isAxiosError(error: unknown): error is AxiosError { + return ( + typeof error === 'object' && + error !== null && + 'isAxiosError' in error && + (error as any).isAxiosError === true + ); +} + diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 6ab882461..b21789fd0 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1,8 +1,8 @@ /// interface ImportMetaEnv { - readonly VITE_API_BASE_URL: string; - readonly VITE_WS_BASE_URL: string; + readonly VITE_API_URL: string; + readonly VITE_WS_URL: string; readonly VITE_STREAM_URL: string; readonly VITE_UPLOAD_URL: string; readonly VITE_APP_NAME: string;