stabilizing apps/web: SITUATION AWARENESS
This commit is contained in:
parent
b9ee16943f
commit
c8c9215e6c
33 changed files with 1598 additions and 576 deletions
456
apps/web/AUDIT_FRONTEND_GAP_ANALYSIS.md
Normal file
456
apps/web/AUDIT_FRONTEND_GAP_ANALYSIS.md
Normal file
|
|
@ -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<string, any>
|
||||
}
|
||||
```
|
||||
|
||||
**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<T>` 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<string, any>;
|
||||
}
|
||||
```
|
||||
|
||||
2. Corriger `ApiResponse<T>` :
|
||||
```typescript
|
||||
export interface ApiResponse<T> {
|
||||
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<string, string>`
|
||||
|
||||
**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.
|
||||
|
||||
657
apps/web/FRONTEND_INTEGRATION.md
Normal file
657
apps/web/FRONTEND_INTEGRATION.md
Normal file
|
|
@ -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 <token>",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentification
|
||||
|
||||
### Format du Token JWT
|
||||
|
||||
Le token JWT est fourni dans le header `Authorization` :
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### 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 <token>
|
||||
```
|
||||
|
||||
**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<string, any>;
|
||||
}
|
||||
|
||||
interface APIResponse<T> {
|
||||
success: boolean;
|
||||
data: T | null;
|
||||
error?: APIError;
|
||||
}
|
||||
```
|
||||
|
||||
### Helper Function
|
||||
|
||||
```typescript
|
||||
async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
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<T> = 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 <token>"
|
||||
```
|
||||
|
||||
**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 <token>"
|
||||
```
|
||||
|
||||
**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 <token>" \
|
||||
-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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { apiClient } from '@/lib/apiClient';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import {
|
||||
AuthResponse,
|
||||
LoginCredentials,
|
||||
|
|
|
|||
|
|
@ -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<typeof loginSchema>;
|
||||
|
||||
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"
|
||||
>
|
||||
<span className="block sm:inline">
|
||||
{(error as any).response?.data?.error || 'Erreur de connexion'}
|
||||
{formatErrorMessage(error as ApiError)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -93,10 +109,10 @@ export const LoginForm = () => {
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-gray-400"
|
||||
>
|
||||
{isPending ? 'Connexion...' : 'Se connecter'}
|
||||
{isLoading ? 'Connexion...' : 'Se connecter'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<typeof registerSchema>;
|
||||
|
||||
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"
|
||||
>
|
||||
<span className="block sm:inline">
|
||||
{(error as any).response?.data?.error || "Erreur d'inscription"}
|
||||
{formatErrorMessage(error as ApiError)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -76,7 +91,7 @@ export const RegisterForm = () => {
|
|||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Nom d'utilisateur (optionnel)
|
||||
Nom d'utilisateur
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -120,10 +135,10 @@ export const RegisterForm = () => {
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-gray-400"
|
||||
>
|
||||
{isPending ? 'Inscription...' : "S'inscrire"}
|
||||
{isLoading ? 'Inscription...' : "S'inscrire"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<AuthResponse, Error, LoginCredentials>({
|
||||
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;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<Error | null>(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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AuthResponse, Error, RegisterCredentials>({
|
||||
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;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<AuthState>()(
|
||||
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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<SyncClient | null>(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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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<string | null>(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() {
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
{(error || storeError) && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription>
|
||||
{error || formatErrorMessage(storeError as ApiError)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<LoginForm onSubmit={handleSubmit} disabled={isLoading} />
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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() {
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
{(error || storeError) && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription>
|
||||
{error || formatErrorMessage(storeError as ApiError)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<RegisterForm onSubmit={handleSubmit} />
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<RegisterResponse> {
|
||||
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<RegisterResponse>('/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<string, unknown>,
|
||||
};
|
||||
|
||||
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<LoginResponse> {
|
||||
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<LoginResponse>('/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<LoginResponse> {
|
|||
|
||||
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<string, unknown>,
|
||||
};
|
||||
|
||||
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<void> {
|
||||
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<User> {
|
||||
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<User>('/auth/me');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const apiError = parseApiError(error);
|
||||
throw apiError;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ApiResponse<any>>) => {
|
||||
// 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<any>;
|
||||
}
|
||||
// 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<ApiResponse<any>>) => {
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<AuthState & AuthActions>()(
|
|||
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<AuthState & AuthActions>()(
|
|||
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<AuthState & AuthActions>()(
|
|||
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<AuthState & AuthActions>()(
|
|||
},
|
||||
|
||||
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<AuthState & AuthActions>()(
|
|||
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<AuthState & AuthActions>()(
|
|||
},
|
||||
|
||||
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<AuthState & AuthActions>()(
|
|||
});
|
||||
} 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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -175,17 +175,26 @@ export interface PaginationData {
|
|||
}
|
||||
|
||||
// Types pour les réponses API
|
||||
// Aligné avec FRONTEND_INTEGRATION.md
|
||||
export interface ApiResponse<T> {
|
||||
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<string, any>;
|
||||
details?: Array<{
|
||||
field: string;
|
||||
message: string;
|
||||
}>;
|
||||
request_id?: string;
|
||||
timestamp: string;
|
||||
context?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
|
|
|
|||
162
apps/web/src/utils/apiErrorHandler.ts
Normal file
162
apps/web/src/utils/apiErrorHandler.ts
Normal file
|
|
@ -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<string, string> {
|
||||
if (!error.details || !Array.isArray(error.details)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const errors: Record<string, string> = {};
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
4
apps/web/src/vite-env.d.ts
vendored
4
apps/web/src/vite-env.d.ts
vendored
|
|
@ -1,8 +1,8 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue