stabilizing apps/web: FIRST BATCH
This commit is contained in:
parent
4b5003bdbe
commit
3d72d5ac3c
49 changed files with 7049 additions and 82 deletions
27
.github/workflows/playwright.yml
vendored
Normal file
27
.github/workflows/playwright.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: npx playwright test
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -76,3 +76,10 @@ veza-backend-api/api
|
|||
veza-backend-api/migrate_tool
|
||||
chat_exports/!veza-stream-server/src/bin/
|
||||
!veza-stream-server/.env
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
|
|
|
|||
128
apps/web/AUDIT_PRODUCTION_FRONTEND.md
Normal file
128
apps/web/AUDIT_PRODUCTION_FRONTEND.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# 🔍 Audit de Production - Frontend Veza
|
||||
|
||||
**Date** : 2025-01-27
|
||||
**Cible** : `apps/web` (Frontend React/Vite/TypeScript)
|
||||
**Objectif** : Identification des bloquants pour un déploiement en production stable et sécurisé
|
||||
|
||||
---
|
||||
|
||||
## 📊 Résumé Exécutif
|
||||
|
||||
### Score Global : **62/100** ⚠️
|
||||
|
||||
**Verdict** : **NON PRÊT POUR LA PRODUCTION**
|
||||
|
||||
Le frontend présente plusieurs problèmes critiques de sécurité et de qualité qui doivent être résolus avant tout déploiement en production.
|
||||
|
||||
### Points Forts ✅
|
||||
|
||||
1. **Architecture** : Routes protégées correctement implémentées avec `ProtectedRoute`
|
||||
2. **TypeScript** : Configuration TypeScript présente et fonctionnelle
|
||||
3. **Build** : Configuration Vite optimisée avec chunk splitting et sourcemaps
|
||||
4. **Tests** : Infrastructure de tests présente (Vitest + Playwright)
|
||||
|
||||
### Points Faibles Majeurs 🔴
|
||||
|
||||
1. **Sécurité Critique** :
|
||||
- CSP avec `unsafe-inline` et `unsafe-eval` (CRITICAL)
|
||||
- Tokens JWT stockés dans `localStorage` (vulnérable au XSS)
|
||||
- Utilisation de `dangerouslySetInnerHTML` sans sanitisation complète
|
||||
|
||||
2. **Qualité du Code** :
|
||||
- 122+ `console.log/error/warn` qui pollueront les logs de production
|
||||
- 480+ utilisations de `any` (perte de sécurité de type)
|
||||
- Hardcoding de `localhost/127.0.0.1` dans plusieurs fichiers
|
||||
|
||||
3. **Gestion d'Erreur** :
|
||||
- Nombreux `catch` qui loggent uniquement sans informer l'utilisateur
|
||||
- Erreurs API silencieuses dans certains composants
|
||||
|
||||
4. **Dette Technique** :
|
||||
- 11 TODO/FIXME/HACK identifiés
|
||||
- Code temporaire (`console.log` avec commentaire "Temporary")
|
||||
|
||||
---
|
||||
|
||||
## 📋 Détail des Problèmes par Catégorie
|
||||
|
||||
### 🔴 CRITICAL (Bloquant Production)
|
||||
|
||||
1. **CSP avec unsafe-inline/unsafe-eval** (`vite.config.ts:64`)
|
||||
- Impact : Vulnérabilité XSS, injection de scripts
|
||||
- Fix : Utiliser des nonces CSP stricts, supprimer `unsafe-eval`
|
||||
|
||||
2. **Tokens dans localStorage** (Multiple fichiers)
|
||||
- Impact : Vol de tokens via XSS
|
||||
- Fix : Migrer vers httpOnly cookies ou sessionStorage avec rotation
|
||||
|
||||
### 🟠 HIGH (Risque Sécurité/Stabilité)
|
||||
|
||||
3. **dangerouslySetInnerHTML sans sanitisation complète** (2 occurrences)
|
||||
- Impact : Risque XSS si sanitisation échoue
|
||||
- Fix : Vérifier `sanitizeChatMessage`, considérer une alternative
|
||||
|
||||
4. **Gestion d'erreur silencieuse** (Multiple fichiers)
|
||||
- Impact : UX dégradée, bugs non visibles
|
||||
- Fix : Afficher des toasts/notifications pour toutes les erreurs utilisateur
|
||||
|
||||
5. **Hardcoding localhost** (Multiple fichiers)
|
||||
- Impact : Build de production avec URLs de dev
|
||||
- Fix : Utiliser uniquement `import.meta.env` avec fallbacks appropriés
|
||||
|
||||
### 🟡 MEDIUM (Qualité/Performance)
|
||||
|
||||
6. **480+ utilisations de `any`**
|
||||
- Impact : Perte de sécurité de type, bugs potentiels
|
||||
- Fix : Typage progressif, interfaces strictes
|
||||
|
||||
7. **122+ console.log/error/warn**
|
||||
- Impact : Pollution des logs de production, fuite d'informations
|
||||
- Fix : Utiliser un logger conditionnel basé sur `import.meta.env.DEV`
|
||||
|
||||
8. **11 TODO/FIXME/HACK**
|
||||
- Impact : Dette technique, fonctionnalités incomplètes
|
||||
- Fix : Prioriser et résoudre ou documenter
|
||||
|
||||
### 🟢 LOW (Cosmétique/Maintenance)
|
||||
|
||||
9. **Code temporaire** (`LibraryManager.tsx:112`)
|
||||
- Impact : Code mort, confusion
|
||||
- Fix : Supprimer ou implémenter la fonctionnalité
|
||||
|
||||
10. **Tests manquants** (Plusieurs composants)
|
||||
- Impact : Risque de régression
|
||||
- Fix : Augmenter la couverture de tests
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Plan d'Action Recommandé
|
||||
|
||||
### Phase 1 : Sécurité Critique (1-2 jours)
|
||||
- [ ] Corriger CSP (supprimer unsafe-inline/unsafe-eval)
|
||||
- [ ] Migrer tokens vers httpOnly cookies
|
||||
- [ ] Audit complet de `dangerouslySetInnerHTML`
|
||||
|
||||
### Phase 2 : Qualité Code (2-3 jours)
|
||||
- [ ] Remplacer tous les `console.*` par un logger conditionnel
|
||||
- [ ] Corriger hardcoding localhost
|
||||
- [ ] Améliorer gestion d'erreur (toasts partout)
|
||||
|
||||
### Phase 3 : Dette Technique (1-2 jours)
|
||||
- [ ] Résoudre/prioriser les TODO
|
||||
- [ ] Réduire les `any` (typage progressif)
|
||||
- [ ] Nettoyer le code temporaire
|
||||
|
||||
---
|
||||
|
||||
## 📈 Métriques
|
||||
|
||||
- **Fichiers analysés** : ~363 fichiers
|
||||
- **Problèmes identifiés** : 50+ (voir JSON détaillé)
|
||||
- **Bloquants production** : 2 (CRITICAL)
|
||||
- **Risques sécurité** : 3 (HIGH)
|
||||
- **Problèmes qualité** : 8 (MEDIUM)
|
||||
- **Améliorations** : 10+ (LOW)
|
||||
|
||||
---
|
||||
|
||||
**Prochaine étape** : Consulter le JSON détaillé (`AUDIT_ISSUES.json`) pour la liste complète des problèmes avec localisation exacte.
|
||||
272
apps/web/DIAGNOSTIC_REPORT.md
Normal file
272
apps/web/DIAGNOSTIC_REPORT.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# Rapport de Diagnostic - Compatibilité Full Stack
|
||||
|
||||
**Date** : 2025-01-27
|
||||
**Test** : Login Flow - Full Stack Compatibility Diagnostic
|
||||
**Environnement** : Frontend (localhost:3000) + Backend (localhost:8080)
|
||||
|
||||
---
|
||||
|
||||
## 1. État Visuel & Navigation
|
||||
|
||||
### ✅ Navigation de Base
|
||||
- **Page accessible** : ✅ Oui (`http://localhost:3000/login`)
|
||||
- **Titre de la page** : ✅ "Veza - Plateforme de streaming musical"
|
||||
- **URL finale** : `http://localhost:3000/login`
|
||||
|
||||
### ❌ Formulaire de Login
|
||||
- **Formulaire visible** : ❌ **NON**
|
||||
- **Inputs email/password** : ❌ **0 inputs détectés sur la page**
|
||||
- **Boutons** : ❌ **0 boutons détectés sur la page**
|
||||
- **Contenu HTML** : La page contient le mot "form" mais aucun élément de formulaire n'est rendu
|
||||
|
||||
**Diagnostic** : Le formulaire React ne se charge pas. La page est essentiellement vide côté contenu interactif.
|
||||
|
||||
---
|
||||
|
||||
## 2. Analyse "Sous le capot" (Backend Compatibility)
|
||||
|
||||
### 🔴 Erreurs Réseau
|
||||
|
||||
**Erreur 500 détectée** lors du chargement des ressources Vite :
|
||||
|
||||
1. **`Failed to load resource: the server responded with a status of 500 (Internal Server Error)`**
|
||||
- **Impact** : Les scripts Vite ne peuvent pas se charger, empêchant React de s'initialiser
|
||||
- **Scripts affectés** :
|
||||
- `/@vite/client` - Client Vite pour le HMR
|
||||
- `/src/main.tsx?t=...` - Point d'entrée React
|
||||
|
||||
**Diagnostic** : Le serveur Vite retourne une erreur 500, probablement due à :
|
||||
- Une erreur de compilation TypeScript/JavaScript
|
||||
- Un problème d'import de module
|
||||
- Une erreur dans le code React qui empêche le build
|
||||
|
||||
#### Requêtes Attendues (si le formulaire fonctionnait) :
|
||||
1. `POST http://localhost:8080/api/v1/auth/login`
|
||||
- **Payload attendu** : `{ email: string, password: string, remember_me?: boolean }`
|
||||
- **Format attendu** : `{ success: true, data: { access_token, refresh_token, expires_in, token_type, user } }`
|
||||
|
||||
#### Erreurs Potentielles à Surveiller :
|
||||
- **401 Unauthorized** : Identifiants invalides
|
||||
- **400 Bad Request** : Format de payload incorrect
|
||||
- **404 Not Found** : Endpoint `/auth/login` introuvable
|
||||
- **500 Internal Server Error** : Erreur serveur
|
||||
- **CORS** : Blocage cross-origin
|
||||
|
||||
### 🔴 Erreurs Console
|
||||
|
||||
**Erreurs capturées** :
|
||||
|
||||
1. **`Failed to load resource: the server responded with a status of 500 (Internal Server Error)`**
|
||||
- **Type** : Error
|
||||
- **Cause** : Le serveur Vite ne peut pas servir les modules JavaScript/TypeScript
|
||||
- **Impact** : React ne peut pas s'initialiser, le formulaire ne se rend pas
|
||||
|
||||
**Messages console capturés** :
|
||||
- `[debug] [vite] connecting...`
|
||||
- `[debug] [vite] connected.`
|
||||
- `[error] Failed to load resource: the server responded with a status of 500 (Internal Server Error)`
|
||||
|
||||
**Diagnostic** : Le problème vient du serveur de développement Vite qui retourne une erreur 500 lors du chargement des modules. Cela empêche complètement le rendu de l'application React.
|
||||
|
||||
**Erreurs TypeScript Détectées** :
|
||||
- Plusieurs erreurs TypeScript dans des composants non liés à l'auth (player, search, forms)
|
||||
- Ces erreurs peuvent empêcher Vite de compiler correctement
|
||||
- **Note** : Ces erreurs existaient probablement avant le refactoring de l'auth
|
||||
|
||||
### 🟠 CORS
|
||||
|
||||
**Aucune erreur CORS détectée** car aucune requête n'a été faite.
|
||||
|
||||
**Configuration Backend Requise** :
|
||||
```go
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||
```
|
||||
|
||||
### 🟢 Token
|
||||
|
||||
**LocalStorage après test** : Non vérifié (le formulaire ne s'est pas chargé)
|
||||
|
||||
**Clés attendues** :
|
||||
- `access_token` ou `veza_access_token`
|
||||
- `refresh_token` ou `veza_refresh_token`
|
||||
|
||||
---
|
||||
|
||||
## 3. Verdict & Bloquants
|
||||
|
||||
### 🔴 **BLOQUANT CRITIQUE #1 : Erreur 500 du Serveur Vite**
|
||||
|
||||
**Problème** : Le serveur Vite retourne une erreur 500 lors du chargement des modules, empêchant React de s'initialiser.
|
||||
|
||||
**Cause Identifiée** :
|
||||
- **Erreur 500** sur `/@vite/client` et `/src/main.tsx`
|
||||
- Les scripts ne peuvent pas se charger
|
||||
- React ne peut pas s'initialiser
|
||||
- Le formulaire ne se rend pas (0 inputs, 0 boutons)
|
||||
|
||||
**Causes Possibles** :
|
||||
1. **Erreur de compilation TypeScript** dans `src/main.tsx` ou un module importé
|
||||
- ✅ **Confirmé** : Plusieurs erreurs TypeScript détectées (player, search, forms)
|
||||
- Ces erreurs empêchent Vite de compiler correctement
|
||||
2. **Erreur d'import** - un module ne peut pas être résolu
|
||||
- Exemples : `@/stores/player`, `@/hooks/use-toast`, `@/components/ui/scroll-area`
|
||||
3. **Erreur de syntaxe** dans un fichier récemment modifié
|
||||
4. **Problème de configuration Vite** - alias ou plugins mal configurés
|
||||
|
||||
**Actions Immédiates** :
|
||||
|
||||
1. **Vérifier les logs du serveur Vite** (PRIORITÉ ABSOLUE) :
|
||||
```bash
|
||||
# Arrêter le serveur actuel (Ctrl+C)
|
||||
# Relancer avec logs détaillés
|
||||
npm run dev
|
||||
# Observer les erreurs de compilation TypeScript/JavaScript
|
||||
```
|
||||
|
||||
2. **Vérifier les erreurs TypeScript** :
|
||||
```bash
|
||||
npm run typecheck
|
||||
# Corriger toutes les erreurs TypeScript
|
||||
```
|
||||
|
||||
3. **Vérifier les erreurs dans la console du navigateur** :
|
||||
- Ouvrir `http://localhost:3000/login` dans un navigateur
|
||||
- Ouvrir DevTools (F12)
|
||||
- Vérifier l'onglet Console pour les erreurs détaillées
|
||||
- Vérifier l'onglet Network pour voir quelle requête retourne 500
|
||||
|
||||
4. **Vérifier les imports récents** :
|
||||
```bash
|
||||
# Vérifier que tous les imports dans les fichiers modifiés sont valides
|
||||
grep -r "from.*@/stores/auth" src/
|
||||
grep -r "from.*@/services/api/auth" src/
|
||||
```
|
||||
|
||||
5. **Vérifier les erreurs de build** :
|
||||
```bash
|
||||
npm run build
|
||||
# Observer les erreurs de compilation
|
||||
```
|
||||
|
||||
### 🟡 **BLOQUANT MOYEN : Test Incomplet**
|
||||
|
||||
**Problème** : Le test ne peut pas continuer car le formulaire ne se charge pas.
|
||||
|
||||
**Améliorations Nécessaires** :
|
||||
1. Capturer les erreurs console dès le chargement de la page
|
||||
2. Prendre des captures d'écran automatiques
|
||||
3. Logger le HTML complet de la page en cas d'échec
|
||||
4. Vérifier si le serveur backend répond avant de tester le login
|
||||
|
||||
---
|
||||
|
||||
## 4. Plan d'Action Immédiat
|
||||
|
||||
### Priorité 1 : Diagnostiquer le Problème de Rendu (30 min)
|
||||
|
||||
1. **Vérifier le serveur frontend** :
|
||||
```bash
|
||||
cd apps/web
|
||||
npm run dev
|
||||
# Ouvrir http://localhost:3000/login dans un navigateur
|
||||
```
|
||||
|
||||
2. **Vérifier les erreurs console** :
|
||||
- Ouvrir DevTools
|
||||
- Vérifier l'onglet Console
|
||||
- Vérifier l'onglet Network pour les erreurs 404/500
|
||||
|
||||
3. **Vérifier le routing** :
|
||||
```bash
|
||||
# Vérifier que la route /login existe
|
||||
grep -r "/login" src/router/
|
||||
```
|
||||
|
||||
4. **Vérifier les imports** :
|
||||
```bash
|
||||
# Vérifier que LoginForm est bien importé dans LoginPage
|
||||
grep -r "LoginForm" src/pages/LoginPage.tsx
|
||||
```
|
||||
|
||||
### Priorité 2 : Une fois le Formulaire Visible (15 min)
|
||||
|
||||
1. **Relancer le test de diagnostic**
|
||||
2. **Vérifier les requêtes réseau** vers `localhost:8080`
|
||||
3. **Vérifier le format du payload** envoyé
|
||||
4. **Vérifier le format de la réponse** reçue
|
||||
5. **Vérifier le stockage du token** dans localStorage
|
||||
|
||||
### Priorité 3 : Tests d'Intégration Complets (30 min)
|
||||
|
||||
1. **Test avec identifiants valides** (si backend disponible)
|
||||
2. **Test avec identifiants invalides** (vérifier les messages d'erreur)
|
||||
3. **Test de redirection** après login réussi
|
||||
4. **Test de persistance** du token (refresh de page)
|
||||
|
||||
---
|
||||
|
||||
## 5. Commandes de Diagnostic
|
||||
|
||||
### Vérifier le Serveur Frontend
|
||||
```bash
|
||||
curl http://localhost:3000/login
|
||||
```
|
||||
|
||||
### Vérifier le Serveur Backend
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/health
|
||||
```
|
||||
|
||||
### Lancer le Test de Diagnostic
|
||||
```bash
|
||||
cd apps/web
|
||||
npx playwright test e2e/diagnostic.spec.ts --reporter=list
|
||||
```
|
||||
|
||||
### Vérifier les Erreurs TypeScript
|
||||
```bash
|
||||
cd apps/web
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Vérifier les Erreurs de Build
|
||||
```bash
|
||||
cd apps/web
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Notes Techniques
|
||||
|
||||
### Configuration Playwright
|
||||
- **Base URL** : `http://localhost:3000` (configuré dans `playwright.config.ts`)
|
||||
- **Timeout** : 30 secondes pour la navigation
|
||||
- **Browser** : Chromium Headless Shell
|
||||
|
||||
### Variables d'Environnement
|
||||
- `VITE_API_URL` : URL du backend (défaut: `http://localhost:8080/api/v1`)
|
||||
- `TEST_EMAIL` : Email de test (défaut: `user@example.com`)
|
||||
- `TEST_PASSWORD` : Mot de passe de test (défaut: `password123`)
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**État Actuel** : 🔴 **BLOQUÉ** - Erreur 500 du serveur Vite empêchant le chargement de React.
|
||||
|
||||
**Cause Racine Identifiée** : Le serveur Vite retourne une erreur 500 lors du chargement des modules (`/@vite/client` et `/src/main.tsx`), probablement due à une erreur de compilation TypeScript ou un problème d'import.
|
||||
|
||||
**Prochaine Étape** :
|
||||
1. **URGENT** : Vérifier les logs du serveur Vite (`npm run dev`) pour identifier l'erreur exacte
|
||||
2. Corriger l'erreur de compilation
|
||||
3. Relancer le serveur
|
||||
4. Relancer le test de diagnostic
|
||||
|
||||
**Une fois le formulaire visible** : Relancer le test de diagnostic pour vérifier la communication avec le backend et le format des requêtes/réponses.
|
||||
|
||||
---
|
||||
|
||||
**Généré par** : Script de diagnostic Playwright (`e2e/diagnostic.spec.ts`)
|
||||
**Date du test** : 2025-01-27
|
||||
|
||||
69
apps/web/RUNTIME_AUDIT_REPORT.md
Normal file
69
apps/web/RUNTIME_AUDIT_REPORT.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Runtime Audit Report
|
||||
|
||||
**Generated:** 2025-12-17T12:09:00.157Z
|
||||
|
||||
---
|
||||
|
||||
## État Global
|
||||
|
||||
**Status:** ❌ UNSTABLE
|
||||
**Login Success:** ✅ Yes
|
||||
|
||||
## Parcours Utilisateur
|
||||
|
||||
| Page | Loaded | Has Content | Load Time (ms) |
|
||||
|------|--------|-------------|----------------|
|
||||
| /dashboard | ✅ | ✅ | 15ms |
|
||||
| /profile | ❌ | ❌ | N/A |
|
||||
| /settings | ❌ | ❌ | N/A |
|
||||
| /library | ❌ | ❌ | N/A |
|
||||
|
||||
## Résumé des Erreurs
|
||||
|
||||
**Total Issues:** 3
|
||||
|
||||
### Par Sévérité
|
||||
|
||||
- **CRITICAL:** 3
|
||||
- **HIGH:** 0
|
||||
- **MEDIUM:** 0
|
||||
- **LOW:** 0
|
||||
|
||||
### Par Catégorie
|
||||
|
||||
- **NETWORK:** 0
|
||||
- **CONSOLE:** 0
|
||||
- **NAVIGATION:** 3
|
||||
- **UX:** 0
|
||||
|
||||
## Erreurs Navigation
|
||||
|
||||
### RUN-001 - CRITICAL
|
||||
|
||||
- **Location:** /profile
|
||||
- **Message:** Failed to navigate to /profile
|
||||
- **Details:** page.waitForURL: Timeout 10000ms exceeded.
|
||||
=========================== logs ===========================
|
||||
waiting for navigation until "domcontentloaded"
|
||||
============================================================
|
||||
- **Reproduction:** Navigate to /profile after login
|
||||
|
||||
### RUN-002 - CRITICAL
|
||||
|
||||
- **Location:** /settings
|
||||
- **Message:** Failed to navigate to /settings
|
||||
- **Details:** page.waitForURL: Timeout 10000ms exceeded.
|
||||
=========================== logs ===========================
|
||||
waiting for navigation until "domcontentloaded"
|
||||
============================================================
|
||||
- **Reproduction:** Navigate to /settings after login
|
||||
|
||||
### RUN-003 - CRITICAL
|
||||
|
||||
- **Location:** /library
|
||||
- **Message:** Failed to navigate to /library
|
||||
- **Details:** page.waitForURL: Timeout 10000ms exceeded.
|
||||
=========================== logs ===========================
|
||||
waiting for navigation until "domcontentloaded"
|
||||
============================================================
|
||||
- **Reproduction:** Navigate to /library after login
|
||||
32
apps/web/RUNTIME_ISSUES.json
Normal file
32
apps/web/RUNTIME_ISSUES.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
[
|
||||
{
|
||||
"category": "NAVIGATION",
|
||||
"severity": "CRITICAL",
|
||||
"location": "/profile",
|
||||
"message": "Failed to navigate to /profile",
|
||||
"details": "page.waitForURL: Timeout 10000ms exceeded.\n=========================== logs ===========================\nwaiting for navigation until \"domcontentloaded\"\n============================================================",
|
||||
"reproduction_steps": "Navigate to /profile after login",
|
||||
"id": "RUN-001",
|
||||
"timestamp": "2025-12-17T12:08:36.888Z"
|
||||
},
|
||||
{
|
||||
"category": "NAVIGATION",
|
||||
"severity": "CRITICAL",
|
||||
"location": "/settings",
|
||||
"message": "Failed to navigate to /settings",
|
||||
"details": "page.waitForURL: Timeout 10000ms exceeded.\n=========================== logs ===========================\nwaiting for navigation until \"domcontentloaded\"\n============================================================",
|
||||
"reproduction_steps": "Navigate to /settings after login",
|
||||
"id": "RUN-002",
|
||||
"timestamp": "2025-12-17T12:08:48.535Z"
|
||||
},
|
||||
{
|
||||
"category": "NAVIGATION",
|
||||
"severity": "CRITICAL",
|
||||
"location": "/library",
|
||||
"message": "Failed to navigate to /library",
|
||||
"details": "page.waitForURL: Timeout 10000ms exceeded.\n=========================== logs ===========================\nwaiting for navigation until \"domcontentloaded\"\n============================================================",
|
||||
"reproduction_steps": "Navigate to /library after login",
|
||||
"id": "RUN-003",
|
||||
"timestamp": "2025-12-17T12:09:00.142Z"
|
||||
}
|
||||
]
|
||||
207
apps/web/e2e-results.json
Normal file
207
apps/web/e2e-results.json
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
{
|
||||
"config": {
|
||||
"configFile": "/home/senke/git/talas/veza/apps/web/playwright.config.ts",
|
||||
"rootDir": "/home/senke/git/talas/veza/apps/web/e2e",
|
||||
"forbidOnly": false,
|
||||
"fullyParallel": true,
|
||||
"globalSetup": null,
|
||||
"globalTeardown": null,
|
||||
"globalTimeout": 0,
|
||||
"grep": {},
|
||||
"grepInvert": null,
|
||||
"maxFailures": 0,
|
||||
"metadata": {
|
||||
"actualWorkers": 1
|
||||
},
|
||||
"preserveOutput": "always",
|
||||
"reporter": [
|
||||
[
|
||||
"html",
|
||||
null
|
||||
],
|
||||
[
|
||||
"json",
|
||||
{
|
||||
"outputFile": "e2e-results.json"
|
||||
}
|
||||
]
|
||||
],
|
||||
"reportSlowTests": {
|
||||
"max": 5,
|
||||
"threshold": 300000
|
||||
},
|
||||
"quiet": false,
|
||||
"projects": [
|
||||
{
|
||||
"outputDir": "/home/senke/git/talas/veza/apps/web/test-results",
|
||||
"repeatEach": 1,
|
||||
"retries": 0,
|
||||
"metadata": {
|
||||
"actualWorkers": 1
|
||||
},
|
||||
"id": "chromium",
|
||||
"name": "chromium",
|
||||
"testDir": "/home/senke/git/talas/veza/apps/web/e2e",
|
||||
"testIgnore": [],
|
||||
"testMatch": [
|
||||
"**/*.@(spec|test).?(c|m)[jt]s?(x)"
|
||||
],
|
||||
"timeout": 30000
|
||||
}
|
||||
],
|
||||
"shard": null,
|
||||
"tags": [],
|
||||
"updateSnapshots": "missing",
|
||||
"updateSourceMethod": "patch",
|
||||
"version": "1.57.0",
|
||||
"workers": 6,
|
||||
"webServer": {
|
||||
"command": "npm run dev",
|
||||
"url": "http://localhost:3000",
|
||||
"reuseExistingServer": true,
|
||||
"timeout": 120000
|
||||
}
|
||||
},
|
||||
"suites": [
|
||||
{
|
||||
"title": "deep_audit.spec.ts",
|
||||
"file": "deep_audit.spec.ts",
|
||||
"column": 0,
|
||||
"line": 0,
|
||||
"specs": [],
|
||||
"suites": [
|
||||
{
|
||||
"title": "Deep E2E Runtime Audit",
|
||||
"file": "deep_audit.spec.ts",
|
||||
"line": 175,
|
||||
"column": 6,
|
||||
"specs": [
|
||||
{
|
||||
"title": "Complete User Journey - Runtime Audit",
|
||||
"ok": true,
|
||||
"tags": [],
|
||||
"tests": [
|
||||
{
|
||||
"timeout": 60000,
|
||||
"annotations": [],
|
||||
"expectedStatus": "passed",
|
||||
"projectId": "chromium",
|
||||
"projectName": "chromium",
|
||||
"results": [
|
||||
{
|
||||
"workerIndex": 0,
|
||||
"parallelIndex": 0,
|
||||
"status": "passed",
|
||||
"duration": 38597,
|
||||
"errors": [],
|
||||
"stdout": [
|
||||
{
|
||||
"text": "🔍 [AUDIT] Starting comprehensive E2E audit...\n"
|
||||
},
|
||||
{
|
||||
"text": "🔍 [AUDIT] Step 1: Navigating to login...\n"
|
||||
},
|
||||
{
|
||||
"text": "🔍 [AUDIT] Step 2: Submitting login form...\n"
|
||||
},
|
||||
{
|
||||
"text": "✅ [AUDIT] Login successful, redirected to: http://localhost:3000/dashboard\n"
|
||||
},
|
||||
{
|
||||
"text": "🔍 [AUDIT] Step 3: Testing page navigation and lazy loading...\n"
|
||||
},
|
||||
{
|
||||
"text": " → Checking /dashboard...\n"
|
||||
},
|
||||
{
|
||||
"text": " → Navigating to /profile...\n"
|
||||
},
|
||||
{
|
||||
"text": " → Navigating to /settings...\n"
|
||||
},
|
||||
{
|
||||
"text": " → Navigating to /library...\n"
|
||||
},
|
||||
{
|
||||
"text": "\n📊 [AUDIT] === AUDIT SUMMARY ===\n"
|
||||
},
|
||||
{
|
||||
"text": "Global Status: UNSTABLE\n"
|
||||
},
|
||||
{
|
||||
"text": "Login Success: true\n"
|
||||
},
|
||||
{
|
||||
"text": "Pages Checked: 4\n"
|
||||
},
|
||||
{
|
||||
"text": "Total Issues: 3\n"
|
||||
},
|
||||
{
|
||||
"text": " - Critical: 3\n"
|
||||
},
|
||||
{
|
||||
"text": " - High: 0\n"
|
||||
},
|
||||
{
|
||||
"text": " - Medium: 0\n"
|
||||
},
|
||||
{
|
||||
"text": " - Low: 0\n"
|
||||
},
|
||||
{
|
||||
"text": "By Category:\n"
|
||||
},
|
||||
{
|
||||
"text": " - NETWORK: 0\n"
|
||||
},
|
||||
{
|
||||
"text": " - CONSOLE: 0\n"
|
||||
},
|
||||
{
|
||||
"text": " - NAVIGATION: 3\n"
|
||||
},
|
||||
{
|
||||
"text": " - UX: 0\n"
|
||||
},
|
||||
{
|
||||
"text": "📄 [AUDIT] JSON report written to: /home/senke/git/talas/veza/apps/web/RUNTIME_ISSUES.json\n"
|
||||
},
|
||||
{
|
||||
"text": "📄 [AUDIT] Markdown report written to: /home/senke/git/talas/veza/apps/web/RUNTIME_AUDIT_REPORT.md\n"
|
||||
}
|
||||
],
|
||||
"stderr": [
|
||||
{
|
||||
"text": "❌ [AUDIT] Application is UNSTABLE\n"
|
||||
}
|
||||
],
|
||||
"retry": 0,
|
||||
"startTime": "2025-12-17T12:08:22.106Z",
|
||||
"annotations": [],
|
||||
"attachments": []
|
||||
}
|
||||
],
|
||||
"status": "expected"
|
||||
}
|
||||
],
|
||||
"id": "31d47f59884aa29aa4c6-10f97fe6bc18ecc2bb0a",
|
||||
"file": "deep_audit.spec.ts",
|
||||
"line": 202,
|
||||
"column": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"stats": {
|
||||
"startTime": "2025-12-17T12:08:21.296Z",
|
||||
"duration": 39518.613,
|
||||
"expected": 1,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
}
|
||||
}
|
||||
710
apps/web/e2e/deep_audit.spec.ts
Normal file
710
apps/web/e2e/deep_audit.spec.ts
Normal file
|
|
@ -0,0 +1,710 @@
|
|||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Deep E2E Audit - Runtime Stability Check
|
||||
*
|
||||
* Ce test effectue un parcours utilisateur complet et capture toutes les erreurs
|
||||
* Runtime, Réseau, et d'Intégration pour valider la stabilité après les corrections
|
||||
* de lazy loading.
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
const FRONTEND_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000';
|
||||
const TEST_EMAIL = process.env.TEST_EMAIL || 'user@example.com';
|
||||
const TEST_PASSWORD = process.env.TEST_PASSWORD || 'password123';
|
||||
|
||||
// Types pour le rapport
|
||||
interface RuntimeIssue {
|
||||
id: string;
|
||||
category: 'NETWORK' | 'CONSOLE' | 'NAVIGATION' | 'UX';
|
||||
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
location: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
reproduction_steps: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
interface PageCheckResult {
|
||||
path: string;
|
||||
loaded: boolean;
|
||||
hasContent: boolean;
|
||||
errors: RuntimeIssue[];
|
||||
loadTime?: number;
|
||||
}
|
||||
|
||||
interface AuditReport {
|
||||
globalStatus: 'STABLE' | 'UNSTABLE';
|
||||
loginSuccess: boolean;
|
||||
pages: PageCheckResult[];
|
||||
allIssues: RuntimeIssue[];
|
||||
summary: {
|
||||
totalIssues: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
byCategory: {
|
||||
NETWORK: number;
|
||||
CONSOLE: number;
|
||||
NAVIGATION: number;
|
||||
UX: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Collecteurs globaux
|
||||
let allIssues: RuntimeIssue[] = [];
|
||||
let issueCounter = 1;
|
||||
|
||||
function generateIssueId(): string {
|
||||
return `RUN-${String(issueCounter++).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
function addIssue(issue: Omit<RuntimeIssue, 'id' | 'timestamp'>): void {
|
||||
allIssues.push({
|
||||
...issue,
|
||||
id: generateIssueId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Helper pour vérifier qu'une page a du contenu
|
||||
async function checkPageHasContent(page: Page, selectors: string[]): Promise<boolean> {
|
||||
// Vérifier d'abord que le body n'est pas vide
|
||||
const bodyText = await page.locator('body').textContent().catch(() => '');
|
||||
if (!bodyText || bodyText.trim().length < 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier les sélecteurs spécifiques
|
||||
for (const selector of selectors) {
|
||||
try {
|
||||
const count = await page.locator(selector).count();
|
||||
if (count > 0) {
|
||||
const element = await page.locator(selector).first();
|
||||
if (await element.isVisible({ timeout: 2000 })) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Si aucun sélecteur spécifique n'est trouvé, vérifier qu'il y a au moins du contenu dans main ou body
|
||||
const mainContent = await page.locator('main, [role="main"], .main-content').first().textContent().catch(() => '');
|
||||
if (mainContent && mainContent.trim().length > 10) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper pour attendre qu'une page charge
|
||||
async function waitForPageLoad(
|
||||
page: Page,
|
||||
expectedPath: string,
|
||||
contentSelectors: string[],
|
||||
timeout = 10000
|
||||
): Promise<PageCheckResult> {
|
||||
const startTime = Date.now();
|
||||
const result: PageCheckResult = {
|
||||
path: expectedPath,
|
||||
loaded: false,
|
||||
hasContent: false,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Vérifier d'abord si on est déjà sur la bonne page
|
||||
const currentPath = new URL(page.url()).pathname;
|
||||
if (currentPath !== expectedPath) {
|
||||
// Attendre la navigation seulement si on n'est pas déjà sur la bonne page
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname === expectedPath,
|
||||
{ timeout, waitUntil: 'domcontentloaded' }
|
||||
);
|
||||
}
|
||||
result.loaded = true;
|
||||
|
||||
// Attendre que le réseau soit idle
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
addIssue({
|
||||
category: 'NAVIGATION',
|
||||
severity: 'MEDIUM',
|
||||
location: expectedPath,
|
||||
message: 'Page took too long to reach networkidle state',
|
||||
details: `Timeout after 5s waiting for networkidle`,
|
||||
reproduction_steps: `Navigate to ${expectedPath}`,
|
||||
});
|
||||
});
|
||||
|
||||
// Vérifier qu'il y a du contenu
|
||||
result.hasContent = await checkPageHasContent(page, contentSelectors);
|
||||
|
||||
if (!result.hasContent) {
|
||||
addIssue({
|
||||
category: 'UX',
|
||||
severity: 'HIGH',
|
||||
location: expectedPath,
|
||||
message: 'Page appears to be blank or empty',
|
||||
details: `None of the expected selectors found: ${contentSelectors.join(', ')}`,
|
||||
reproduction_steps: `Navigate to ${expectedPath} after login`,
|
||||
});
|
||||
}
|
||||
|
||||
result.loadTime = Date.now() - startTime;
|
||||
} catch (error) {
|
||||
result.loaded = false;
|
||||
addIssue({
|
||||
category: 'NAVIGATION',
|
||||
severity: 'CRITICAL',
|
||||
location: expectedPath,
|
||||
message: `Failed to navigate to ${expectedPath}`,
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
reproduction_steps: `Navigate to ${expectedPath} after login`,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
test.describe('Deep E2E Runtime Audit', () => {
|
||||
let report: AuditReport;
|
||||
|
||||
test.beforeEach(() => {
|
||||
allIssues = [];
|
||||
issueCounter = 1;
|
||||
report = {
|
||||
globalStatus: 'STABLE',
|
||||
loginSuccess: false,
|
||||
pages: [],
|
||||
allIssues: [],
|
||||
summary: {
|
||||
totalIssues: 0,
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
byCategory: {
|
||||
NETWORK: 0,
|
||||
CONSOLE: 0,
|
||||
NAVIGATION: 0,
|
||||
UX: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
test('Complete User Journey - Runtime Audit', async ({ page, context }) => {
|
||||
test.setTimeout(60000); // 60 secondes pour le test complet
|
||||
console.log('🔍 [AUDIT] Starting comprehensive E2E audit...');
|
||||
|
||||
// ============================================
|
||||
// PHASE 1: Setup Error Listeners
|
||||
// ============================================
|
||||
|
||||
// Console errors & warnings
|
||||
page.on('console', (msg) => {
|
||||
const type = msg.type();
|
||||
const text = msg.text();
|
||||
const location = page.url();
|
||||
|
||||
if (type === 'error') {
|
||||
addIssue({
|
||||
category: 'CONSOLE',
|
||||
severity: 'HIGH',
|
||||
location,
|
||||
message: text,
|
||||
details: `Console error: ${text}`,
|
||||
reproduction_steps: `Navigate to ${location}`,
|
||||
});
|
||||
console.log(`🔴 [CONSOLE ERROR] ${text}`);
|
||||
} else if (type === 'warning') {
|
||||
// Même les warnings sont capturés (comme demandé)
|
||||
addIssue({
|
||||
category: 'CONSOLE',
|
||||
severity: 'MEDIUM',
|
||||
location,
|
||||
message: text,
|
||||
details: `Console warning: ${text}`,
|
||||
reproduction_steps: `Navigate to ${location}`,
|
||||
});
|
||||
console.log(`🟡 [CONSOLE WARNING] ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Page errors (uncaught exceptions)
|
||||
page.on('pageerror', (error) => {
|
||||
addIssue({
|
||||
category: 'CONSOLE',
|
||||
severity: 'CRITICAL',
|
||||
location: page.url(),
|
||||
message: error.message,
|
||||
details: error.stack,
|
||||
reproduction_steps: `Navigate to ${page.url()}`,
|
||||
});
|
||||
console.log(`🔴 [PAGE ERROR] ${error.message}`);
|
||||
});
|
||||
|
||||
// Network errors (4xx, 5xx)
|
||||
page.on('response', async (response) => {
|
||||
const status = response.status();
|
||||
const url = response.url();
|
||||
const method = response.request().method();
|
||||
|
||||
if (status >= 400) {
|
||||
const severity = status >= 500 ? 'CRITICAL' : status >= 400 ? 'HIGH' : 'MEDIUM';
|
||||
|
||||
// Essayer de récupérer le body de l'erreur pour plus de détails
|
||||
let errorDetails = `Server responded with status ${status}`;
|
||||
try {
|
||||
const responseBody = await response.text().catch(() => '');
|
||||
if (responseBody) {
|
||||
try {
|
||||
const parsed = JSON.parse(responseBody);
|
||||
if (parsed && parsed.error) {
|
||||
errorDetails = `${errorDetails}. Error: ${parsed.error}`;
|
||||
} else if (parsed && parsed.message) {
|
||||
errorDetails = `${errorDetails}. Message: ${parsed.message}`;
|
||||
}
|
||||
} catch {
|
||||
// Si ce n'est pas du JSON, prendre un extrait du texte
|
||||
if (responseBody.length < 200) {
|
||||
errorDetails = `${errorDetails}. Response: ${responseBody.substring(0, 200)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore si on ne peut pas parser la réponse
|
||||
}
|
||||
|
||||
addIssue({
|
||||
category: 'NETWORK',
|
||||
severity,
|
||||
location: page.url(),
|
||||
message: `HTTP ${status} - ${method} ${url}`,
|
||||
details: errorDetails,
|
||||
reproduction_steps: `Navigate to ${page.url()}`,
|
||||
});
|
||||
console.log(`🔴 [NETWORK ERROR] ${method} ${url} -> ${status}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Failed requests (network failures)
|
||||
page.on('requestfailed', (request) => {
|
||||
const failure = request.failure();
|
||||
if (failure) {
|
||||
const url = request.url();
|
||||
const method = request.method();
|
||||
|
||||
// Ne pas reporter les erreurs de favicon ou de ressources statiques non critiques
|
||||
if (url.includes('favicon') || url.includes('.ico') || url.includes('chrome-extension')) {
|
||||
return;
|
||||
}
|
||||
|
||||
addIssue({
|
||||
category: 'NETWORK',
|
||||
severity: 'CRITICAL',
|
||||
location: page.url(),
|
||||
message: `Request failed: ${method} ${url}`,
|
||||
details: failure.errorText || 'Network error',
|
||||
reproduction_steps: `Navigate to ${page.url()}`,
|
||||
});
|
||||
console.log(`🔴 [REQUEST FAILED] ${method} ${url}: ${failure.errorText}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// PHASE 2: Login Flow
|
||||
// ============================================
|
||||
|
||||
console.log('🔍 [AUDIT] Step 1: Navigating to login...');
|
||||
await page.goto(`${FRONTEND_URL}/login`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {
|
||||
console.warn('⚠️ [AUDIT] Timeout on networkidle for login page');
|
||||
});
|
||||
|
||||
// Attendre que le formulaire soit chargé
|
||||
await page.waitForSelector('input[type="email"], input[name="email"]', {
|
||||
timeout: 10000,
|
||||
}).catch(() => {
|
||||
addIssue({
|
||||
category: 'UX',
|
||||
severity: 'CRITICAL',
|
||||
location: '/login',
|
||||
message: 'Login form not found',
|
||||
details: 'Email input field not visible',
|
||||
reproduction_steps: 'Navigate to /login',
|
||||
});
|
||||
});
|
||||
|
||||
// Remplir le formulaire
|
||||
const emailInput = page.locator('input[type="email"], input[name="email"]').first();
|
||||
const passwordInput = page.locator('input[type="password"]').first();
|
||||
const submitButton = page.locator('button[type="submit"], button:has-text("connecter"), button:has-text("login"), button:has-text("Se connecter")').first();
|
||||
|
||||
await emailInput.fill(TEST_EMAIL);
|
||||
await passwordInput.fill(TEST_PASSWORD);
|
||||
|
||||
console.log('🔍 [AUDIT] Step 2: Submitting login form...');
|
||||
|
||||
// Attendre la navigation après login
|
||||
await submitButton.click();
|
||||
|
||||
// Attendre soit la navigation, soit un message d'erreur
|
||||
try {
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname === '/dashboard' || url.pathname === '/',
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
report.loginSuccess = true;
|
||||
console.log('✅ [AUDIT] Login successful, redirected to:', page.url());
|
||||
} catch (error) {
|
||||
// Vérifier si on est toujours sur /login ou si on a une erreur
|
||||
const currentUrl = page.url();
|
||||
const currentPath = new URL(currentUrl).pathname;
|
||||
|
||||
if (currentPath === '/login') {
|
||||
report.loginSuccess = false;
|
||||
addIssue({
|
||||
category: 'NAVIGATION',
|
||||
severity: 'CRITICAL',
|
||||
location: '/login',
|
||||
message: 'Login failed or did not redirect',
|
||||
details: `Still on ${currentUrl} after login attempt. Check for error messages or network failures.`,
|
||||
reproduction_steps: `Login with ${TEST_EMAIL}`,
|
||||
});
|
||||
console.error('❌ [AUDIT] Login failed or did not redirect');
|
||||
|
||||
// Si le login échoue, on génère quand même le rapport avec les erreurs capturées
|
||||
report.allIssues = allIssues;
|
||||
report.summary.totalIssues = allIssues.length;
|
||||
report.summary.critical = allIssues.filter((i) => i.severity === 'CRITICAL').length;
|
||||
report.summary.high = allIssues.filter((i) => i.severity === 'HIGH').length;
|
||||
report.summary.medium = allIssues.filter((i) => i.severity === 'MEDIUM').length;
|
||||
report.summary.low = allIssues.filter((i) => i.severity === 'LOW').length;
|
||||
report.summary.byCategory.NETWORK = allIssues.filter((i) => i.category === 'NETWORK').length;
|
||||
report.summary.byCategory.CONSOLE = allIssues.filter((i) => i.category === 'CONSOLE').length;
|
||||
report.summary.byCategory.NAVIGATION = allIssues.filter((i) => i.category === 'NAVIGATION').length;
|
||||
report.summary.byCategory.UX = allIssues.filter((i) => i.category === 'UX').length;
|
||||
report.globalStatus = 'UNSTABLE';
|
||||
|
||||
// Sauvegarder le rapport même en cas d'échec
|
||||
await page.evaluate((report) => {
|
||||
(window as any).__auditReport = report;
|
||||
}, report);
|
||||
|
||||
return;
|
||||
} else {
|
||||
// On a navigué ailleurs (peut-être une page d'erreur ou autre)
|
||||
report.loginSuccess = false;
|
||||
addIssue({
|
||||
category: 'NAVIGATION',
|
||||
severity: 'HIGH',
|
||||
location: currentPath,
|
||||
message: 'Login redirected to unexpected page',
|
||||
details: `Expected /dashboard but got ${currentUrl}`,
|
||||
reproduction_steps: `Login with ${TEST_EMAIL}`,
|
||||
});
|
||||
console.warn('⚠️ [AUDIT] Login redirected to unexpected page:', currentUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Attendre un peu pour que l'app se stabilise
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// ============================================
|
||||
// PHASE 3: Navigation & Lazy Loading Check
|
||||
// ============================================
|
||||
|
||||
console.log('🔍 [AUDIT] Step 3: Testing page navigation and lazy loading...');
|
||||
|
||||
// Dashboard (déjà chargé)
|
||||
console.log(' → Checking /dashboard...');
|
||||
// S'assurer qu'on est bien sur /dashboard
|
||||
if (new URL(page.url()).pathname !== '/dashboard') {
|
||||
await page.goto(`${FRONTEND_URL}/dashboard`, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
const dashboardCheck = await waitForPageLoad(
|
||||
page,
|
||||
'/dashboard',
|
||||
['[data-testid="dashboard"]', 'h1', 'main', '.container', 'nav', 'aside'],
|
||||
);
|
||||
report.pages.push(dashboardCheck);
|
||||
|
||||
// Profile page
|
||||
console.log(' → Navigating to /profile...');
|
||||
await page.goto(`${FRONTEND_URL}/profile`, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(1000); // Attendre que le lazy loading se stabilise
|
||||
const profileCheck = await waitForPageLoad(
|
||||
page,
|
||||
'/profile',
|
||||
['h1', 'main', '.container', '[data-testid="profile"]', 'form', 'button'],
|
||||
);
|
||||
report.pages.push(profileCheck);
|
||||
|
||||
// Settings page
|
||||
console.log(' → Navigating to /settings...');
|
||||
await page.goto(`${FRONTEND_URL}/settings`, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(1000); // Attendre que le lazy loading se stabilise
|
||||
const settingsCheck = await waitForPageLoad(
|
||||
page,
|
||||
'/settings',
|
||||
['h1:has-text("Paramètres"), h1:has-text("Settings"), h1', 'main', '.container', 'form', 'button'],
|
||||
);
|
||||
report.pages.push(settingsCheck);
|
||||
|
||||
// Library page
|
||||
console.log(' → Navigating to /library...');
|
||||
await page.goto(`${FRONTEND_URL}/library`, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(1000); // Attendre que le lazy loading se stabilise
|
||||
const libraryCheck = await waitForPageLoad(
|
||||
page,
|
||||
'/library',
|
||||
['h1', 'main', '.container', '[data-testid="library"]', 'button', 'div'],
|
||||
);
|
||||
report.pages.push(libraryCheck);
|
||||
|
||||
// ============================================
|
||||
// PHASE 4: Generate Report
|
||||
// ============================================
|
||||
|
||||
report.allIssues = allIssues;
|
||||
|
||||
// Calculer le résumé
|
||||
report.summary.totalIssues = allIssues.length;
|
||||
report.summary.critical = allIssues.filter((i) => i.severity === 'CRITICAL').length;
|
||||
report.summary.high = allIssues.filter((i) => i.severity === 'HIGH').length;
|
||||
report.summary.medium = allIssues.filter((i) => i.severity === 'MEDIUM').length;
|
||||
report.summary.low = allIssues.filter((i) => i.severity === 'LOW').length;
|
||||
|
||||
report.summary.byCategory.NETWORK = allIssues.filter((i) => i.category === 'NETWORK').length;
|
||||
report.summary.byCategory.CONSOLE = allIssues.filter((i) => i.category === 'CONSOLE').length;
|
||||
report.summary.byCategory.NAVIGATION = allIssues.filter((i) => i.category === 'NAVIGATION').length;
|
||||
report.summary.byCategory.UX = allIssues.filter((i) => i.category === 'UX').length;
|
||||
|
||||
// Déterminer le statut global
|
||||
if (
|
||||
report.summary.critical > 0 ||
|
||||
!report.loginSuccess ||
|
||||
report.pages.some((p) => !p.loaded || !p.hasContent)
|
||||
) {
|
||||
report.globalStatus = 'UNSTABLE';
|
||||
}
|
||||
|
||||
// Afficher le résumé dans la console
|
||||
console.log('\n📊 [AUDIT] === AUDIT SUMMARY ===');
|
||||
console.log(`Global Status: ${report.globalStatus}`);
|
||||
console.log(`Login Success: ${report.loginSuccess}`);
|
||||
console.log(`Pages Checked: ${report.pages.length}`);
|
||||
console.log(`Total Issues: ${report.summary.totalIssues}`);
|
||||
console.log(` - Critical: ${report.summary.critical}`);
|
||||
console.log(` - High: ${report.summary.high}`);
|
||||
console.log(` - Medium: ${report.summary.medium}`);
|
||||
console.log(` - Low: ${report.summary.low}`);
|
||||
console.log(`By Category:`);
|
||||
console.log(` - NETWORK: ${report.summary.byCategory.NETWORK}`);
|
||||
console.log(` - CONSOLE: ${report.summary.byCategory.CONSOLE}`);
|
||||
console.log(` - NAVIGATION: ${report.summary.byCategory.NAVIGATION}`);
|
||||
console.log(` - UX: ${report.summary.byCategory.UX}`);
|
||||
|
||||
// Sauvegarder le rapport dans la page pour récupération
|
||||
await page.evaluate((report) => {
|
||||
(window as any).__auditReport = report;
|
||||
}, report);
|
||||
|
||||
// Assertions finales (ne pas faire échouer le test, juste logger)
|
||||
if (report.globalStatus === 'UNSTABLE') {
|
||||
console.error('❌ [AUDIT] Application is UNSTABLE');
|
||||
} else {
|
||||
console.log('✅ [AUDIT] Application appears STABLE');
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
// Récupérer le rapport depuis la page
|
||||
const savedReport = await page
|
||||
.evaluate(() => {
|
||||
return (window as any).__auditReport;
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (savedReport) {
|
||||
report = savedReport;
|
||||
}
|
||||
|
||||
// Écrire les rapports dans des fichiers
|
||||
// Utiliser process.cwd() car __dirname peut ne pas être disponible en ESM
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
// Rapport JSON
|
||||
const jsonPath = join(projectRoot, 'RUNTIME_ISSUES.json');
|
||||
writeFileSync(jsonPath, JSON.stringify(report.allIssues, null, 2));
|
||||
console.log(`📄 [AUDIT] JSON report written to: ${jsonPath}`);
|
||||
|
||||
// Rapport Markdown
|
||||
const mdPath = join(projectRoot, 'RUNTIME_AUDIT_REPORT.md');
|
||||
const mdContent = generateMarkdownReport(report);
|
||||
writeFileSync(mdPath, mdContent);
|
||||
console.log(`📄 [AUDIT] Markdown report written to: ${mdPath}`);
|
||||
});
|
||||
});
|
||||
|
||||
function generateMarkdownReport(report: AuditReport): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('# Runtime Audit Report');
|
||||
lines.push('');
|
||||
lines.push(`**Generated:** ${new Date().toISOString()}`);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
|
||||
// État Global
|
||||
lines.push('## État Global');
|
||||
lines.push('');
|
||||
lines.push(`**Status:** ${report.globalStatus === 'STABLE' ? '✅ STABLE' : '❌ UNSTABLE'}`);
|
||||
lines.push(`**Login Success:** ${report.loginSuccess ? '✅ Yes' : '❌ No'}`);
|
||||
lines.push('');
|
||||
|
||||
// Parcours
|
||||
lines.push('## Parcours Utilisateur');
|
||||
lines.push('');
|
||||
lines.push('| Page | Loaded | Has Content | Load Time (ms) |');
|
||||
lines.push('|------|--------|-------------|----------------|');
|
||||
for (const page of report.pages) {
|
||||
const loaded = page.loaded ? '✅' : '❌';
|
||||
const content = page.hasContent ? '✅' : '❌';
|
||||
const loadTime = page.loadTime ? `${page.loadTime}ms` : 'N/A';
|
||||
lines.push(`| ${page.path} | ${loaded} | ${content} | ${loadTime} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Résumé des erreurs
|
||||
lines.push('## Résumé des Erreurs');
|
||||
lines.push('');
|
||||
lines.push(`**Total Issues:** ${report.summary.totalIssues}`);
|
||||
lines.push('');
|
||||
lines.push('### Par Sévérité');
|
||||
lines.push('');
|
||||
lines.push(`- **CRITICAL:** ${report.summary.critical}`);
|
||||
lines.push(`- **HIGH:** ${report.summary.high}`);
|
||||
lines.push(`- **MEDIUM:** ${report.summary.medium}`);
|
||||
lines.push(`- **LOW:** ${report.summary.low}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('### Par Catégorie');
|
||||
lines.push('');
|
||||
lines.push(`- **NETWORK:** ${report.summary.byCategory.NETWORK}`);
|
||||
lines.push(`- **CONSOLE:** ${report.summary.byCategory.CONSOLE}`);
|
||||
lines.push(`- **NAVIGATION:** ${report.summary.byCategory.NAVIGATION}`);
|
||||
lines.push(`- **UX:** ${report.summary.byCategory.UX}`);
|
||||
lines.push('');
|
||||
|
||||
// Erreurs Console
|
||||
const consoleErrors = report.allIssues.filter((i) => i.category === 'CONSOLE');
|
||||
if (consoleErrors.length > 0) {
|
||||
lines.push('## Erreurs Console');
|
||||
lines.push('');
|
||||
for (const error of consoleErrors) {
|
||||
lines.push(`### ${error.id} - ${error.severity}`);
|
||||
lines.push('');
|
||||
lines.push(`- **Location:** ${error.location}`);
|
||||
lines.push(`- **Message:** ${error.message}`);
|
||||
if (error.details) {
|
||||
lines.push(`- **Details:** ${error.details}`);
|
||||
}
|
||||
lines.push(`- **Reproduction:** ${error.reproduction_steps}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Erreurs Réseau
|
||||
const networkErrors = report.allIssues.filter((i) => i.category === 'NETWORK');
|
||||
if (networkErrors.length > 0) {
|
||||
lines.push('## Erreurs Réseau');
|
||||
lines.push('');
|
||||
for (const error of networkErrors) {
|
||||
lines.push(`### ${error.id} - ${error.severity}`);
|
||||
lines.push('');
|
||||
lines.push(`- **Location:** ${error.location}`);
|
||||
lines.push(`- **Message:** ${error.message}`);
|
||||
if (error.details) {
|
||||
lines.push(`- **Details:** ${error.details}`);
|
||||
}
|
||||
lines.push(`- **Reproduction:** ${error.reproduction_steps}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Erreurs Navigation
|
||||
const navErrors = report.allIssues.filter((i) => i.category === 'NAVIGATION');
|
||||
if (navErrors.length > 0) {
|
||||
lines.push('## Erreurs Navigation');
|
||||
lines.push('');
|
||||
for (const error of navErrors) {
|
||||
lines.push(`### ${error.id} - ${error.severity}`);
|
||||
lines.push('');
|
||||
lines.push(`- **Location:** ${error.location}`);
|
||||
lines.push(`- **Message:** ${error.message}`);
|
||||
if (error.details) {
|
||||
lines.push(`- **Details:** ${error.details}`);
|
||||
}
|
||||
lines.push(`- **Reproduction:** ${error.reproduction_steps}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Erreurs UX
|
||||
const uxErrors = report.allIssues.filter((i) => i.category === 'UX');
|
||||
if (uxErrors.length > 0) {
|
||||
lines.push('## Erreurs UX');
|
||||
lines.push('');
|
||||
for (const error of uxErrors) {
|
||||
lines.push(`### ${error.id} - ${error.severity}`);
|
||||
lines.push('');
|
||||
lines.push(`- **Location:** ${error.location}`);
|
||||
lines.push(`- **Message:** ${error.message}`);
|
||||
if (error.details) {
|
||||
lines.push(`- **Details:** ${error.details}`);
|
||||
}
|
||||
lines.push(`- **Reproduction:** ${error.reproduction_steps}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
if (report.allIssues.length === 0) {
|
||||
lines.push('## ✅ Aucune Erreur Détectée');
|
||||
lines.push('');
|
||||
lines.push('L\'application semble stable. Aucune erreur runtime, réseau ou d\'intégration n\'a été détectée.');
|
||||
}
|
||||
|
||||
// Note sur les limitations du test
|
||||
if (!report.loginSuccess) {
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
lines.push('## ⚠️ Note sur les Limitations du Test');
|
||||
lines.push('');
|
||||
lines.push('Le test n\'a pas pu continuer au-delà de la page de login car le backend n\'était pas accessible.');
|
||||
lines.push('Les pages protégées (Dashboard, Profile, Settings, Library) n\'ont donc pas pu être testées.');
|
||||
lines.push('');
|
||||
lines.push('**Pour un audit complet :**');
|
||||
lines.push('1. Démarrer le backend API sur `http://localhost:8080`');
|
||||
lines.push('2. Configurer CORS pour autoriser les requêtes depuis `http://localhost:3000`');
|
||||
lines.push('3. Relancer le test avec `npx playwright test e2e/deep_audit.spec.ts`');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
BIN
apps/web/e2e/diagnostic-login-page.png
Normal file
BIN
apps/web/e2e/diagnostic-login-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
346
apps/web/e2e/diagnostic.spec.ts
Normal file
346
apps/web/e2e/diagnostic.spec.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Diagnostic Test - Full Stack Compatibility Check
|
||||
*
|
||||
* Ce test vérifie l'intégration Frontend-Backend après le refactoring de l'authentification.
|
||||
* Il capture toutes les erreurs réseau, console, CORS et vérifie le stockage des tokens.
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
const BASE_URL = process.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const FRONTEND_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000';
|
||||
const TEST_EMAIL = process.env.TEST_EMAIL || 'user@example.com';
|
||||
const TEST_PASSWORD = process.env.TEST_PASSWORD || 'password123';
|
||||
|
||||
// Collecteurs d'erreurs
|
||||
interface DiagnosticReport {
|
||||
networkErrors: Array<{
|
||||
url: string;
|
||||
status: number;
|
||||
method: string;
|
||||
error: string;
|
||||
}>;
|
||||
consoleErrors: Array<{
|
||||
type: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
}>;
|
||||
corsErrors: Array<{
|
||||
url: string;
|
||||
reason: string;
|
||||
}>;
|
||||
localStorage: Record<string, string>;
|
||||
navigationSuccess: boolean;
|
||||
finalUrl: string;
|
||||
formVisible: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
test.describe('Full Stack Compatibility Diagnostic', () => {
|
||||
let report: DiagnosticReport;
|
||||
|
||||
test.beforeEach(() => {
|
||||
report = {
|
||||
networkErrors: [],
|
||||
consoleErrors: [],
|
||||
corsErrors: [],
|
||||
localStorage: {},
|
||||
navigationSuccess: false,
|
||||
finalUrl: '',
|
||||
formVisible: false,
|
||||
};
|
||||
});
|
||||
|
||||
test('Login Flow - Complete Diagnostic', async ({ page, context }) => {
|
||||
// Setup: Écouter les erreurs console AVANT toute navigation
|
||||
const consoleMessages: Array<{ type: string; text: string }> = [];
|
||||
page.on('console', (msg) => {
|
||||
const type = msg.type();
|
||||
const text = msg.text();
|
||||
consoleMessages.push({ type, text });
|
||||
|
||||
if (type === 'error' || type === 'warning') {
|
||||
report.consoleErrors.push({
|
||||
type,
|
||||
message: text,
|
||||
stack: msg.location()?.url,
|
||||
});
|
||||
console.log(`🔴 [CONSOLE ${type.toUpperCase()}] ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Setup: Écouter les erreurs de page (uncaught exceptions)
|
||||
page.on('pageerror', (error) => {
|
||||
report.consoleErrors.push({
|
||||
type: 'pageerror',
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
console.log(`🔴 [PAGE ERROR] ${error.message}`);
|
||||
});
|
||||
|
||||
// Setup: Écouter les requêtes réseau échouées
|
||||
page.on('response', (response) => {
|
||||
const status = response.status();
|
||||
const url = response.url();
|
||||
|
||||
// Capturer les erreurs 4xx et 5xx
|
||||
if (status >= 400) {
|
||||
report.networkErrors.push({
|
||||
url,
|
||||
status,
|
||||
method: response.request().method(),
|
||||
error: `HTTP ${status}`,
|
||||
});
|
||||
|
||||
// Détecter les erreurs CORS potentielles
|
||||
if (status === 0 || url.includes('localhost:8080')) {
|
||||
const headers = response.headers();
|
||||
if (!headers['access-control-allow-origin']) {
|
||||
report.corsErrors.push({
|
||||
url,
|
||||
reason: 'Missing CORS headers',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Setup: Écouter les requêtes échouées (network errors)
|
||||
page.on('requestfailed', (request) => {
|
||||
const failure = request.failure();
|
||||
if (failure) {
|
||||
report.networkErrors.push({
|
||||
url: request.url(),
|
||||
status: 0,
|
||||
method: request.method(),
|
||||
error: failure.errorText || 'Network error',
|
||||
});
|
||||
|
||||
// Détecter les erreurs CORS
|
||||
if (failure.errorText?.includes('CORS') || failure.errorText?.includes('Access-Control')) {
|
||||
report.corsErrors.push({
|
||||
url: request.url(),
|
||||
reason: failure.errorText,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Étape 1: Aller sur la page de login
|
||||
console.log('🔍 [DIAGNOSTIC] Navigation vers /login...');
|
||||
try {
|
||||
await page.goto(`${FRONTEND_URL}/login`, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
} catch (error) {
|
||||
console.error('❌ [DIAGNOSTIC] Erreur lors de la navigation:', error);
|
||||
report.finalUrl = page.url();
|
||||
return;
|
||||
}
|
||||
|
||||
// Attendre que la page soit chargée
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {
|
||||
console.warn('⚠️ [DIAGNOSTIC] Timeout sur networkidle, continuation...');
|
||||
});
|
||||
|
||||
// Prendre une capture d'écran pour debug
|
||||
await page.screenshot({ path: 'e2e/diagnostic-login-page.png', fullPage: true });
|
||||
|
||||
// Attendre que le formulaire soit chargé (plusieurs stratégies)
|
||||
try {
|
||||
// Essayer d'attendre un élément de formulaire
|
||||
await page.waitForSelector('form, input[type="email"], input[type="password"]', { timeout: 10000 });
|
||||
} catch (e) {
|
||||
console.warn('⚠️ [DIAGNOSTIC] Timeout en attendant le formulaire');
|
||||
}
|
||||
|
||||
// Attendre un peu pour que React hydrate
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Vérifier que le formulaire est visible avec plusieurs sélecteurs possibles
|
||||
const emailInput = page.locator('input[type="email"], input[name="email"], input[placeholder*="email" i]').first();
|
||||
const passwordInput = page.locator('input[type="password"], input[name="password"]').first();
|
||||
const submitButton = page.locator('button[type="submit"], button:has-text("connecter"), button:has-text("login"), button:has-text("Se connecter")').first();
|
||||
|
||||
// Vérifier aussi avec des sélecteurs plus génériques
|
||||
const allInputs = await page.locator('input').count();
|
||||
const allButtons = await page.locator('button').count();
|
||||
console.log('📄 [DIAGNOSTIC] Nombre d\'inputs sur la page:', allInputs);
|
||||
console.log('📄 [DIAGNOSTIC] Nombre de boutons sur la page:', allButtons);
|
||||
|
||||
const emailVisible = await emailInput.isVisible().catch(() => false);
|
||||
const passwordVisible = await passwordInput.isVisible().catch(() => false);
|
||||
const submitVisible = await submitButton.isVisible().catch(() => false);
|
||||
|
||||
report.formVisible = emailVisible && passwordVisible;
|
||||
|
||||
// Logger le contenu de la page pour debug
|
||||
const pageContent = await page.content();
|
||||
const hasForm = pageContent.includes('form') || pageContent.includes('email') || pageContent.includes('password');
|
||||
|
||||
console.log('📄 [DIAGNOSTIC] Page title:', await page.title());
|
||||
console.log('📄 [DIAGNOSTIC] URL actuelle:', page.url());
|
||||
console.log('📄 [DIAGNOSTIC] Email input visible:', emailVisible);
|
||||
console.log('📄 [DIAGNOSTIC] Password input visible:', passwordVisible);
|
||||
console.log('📄 [DIAGNOSTIC] Submit button visible:', submitVisible);
|
||||
console.log('📄 [DIAGNOSTIC] Page contient "form":', hasForm);
|
||||
|
||||
if (!report.formVisible) {
|
||||
console.error('❌ [DIAGNOSTIC] Le formulaire de login n\'est pas visible');
|
||||
|
||||
// Logger le HTML pour debug
|
||||
const bodyText = await page.locator('body').textContent();
|
||||
console.log('📄 [DIAGNOSTIC] Contenu de la page (premiers 500 chars):', bodyText?.substring(0, 500));
|
||||
|
||||
// Logger toutes les erreurs console capturées
|
||||
if (consoleMessages.length > 0) {
|
||||
console.log('\n🔴 [DIAGNOSTIC] Messages console capturés:');
|
||||
consoleMessages.forEach((msg) => {
|
||||
console.log(` [${msg.type}] ${msg.text}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier s'il y a des scripts qui ont échoué à charger
|
||||
const failedResources = await page.evaluate(() => {
|
||||
const resources: Array<{ url: string; error: string }> = [];
|
||||
const scripts = document.querySelectorAll('script[src]');
|
||||
scripts.forEach((script) => {
|
||||
const src = script.getAttribute('src');
|
||||
if (src && !(script as any).loaded) {
|
||||
resources.push({ url: src, error: 'Script not loaded' });
|
||||
}
|
||||
});
|
||||
return resources;
|
||||
});
|
||||
|
||||
if (failedResources.length > 0) {
|
||||
console.log('🔴 [DIAGNOSTIC] Scripts non chargés:', failedResources);
|
||||
}
|
||||
|
||||
// Sauvegarder le rapport même en cas d'échec
|
||||
await page.evaluate((report) => {
|
||||
(window as any).__diagnosticReport = report;
|
||||
}, report);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ [DIAGNOSTIC] Formulaire de login visible');
|
||||
|
||||
// Étape 2: Remplir le formulaire
|
||||
console.log('🔍 [DIAGNOSTIC] Remplissage du formulaire...');
|
||||
await emailInput.fill(TEST_EMAIL);
|
||||
await passwordInput.fill(TEST_PASSWORD);
|
||||
|
||||
// Vérifier si checkbox "remember me" existe
|
||||
const rememberMeCheckbox = page.locator('input[type="checkbox"][id*="remember"]');
|
||||
if (await rememberMeCheckbox.count() > 0) {
|
||||
await rememberMeCheckbox.check();
|
||||
}
|
||||
|
||||
// Étape 3: Cliquer sur le bouton de connexion
|
||||
console.log('🔍 [DIAGNOSTIC] Clic sur le bouton de connexion...');
|
||||
|
||||
// Attendre la navigation ou un message d'erreur
|
||||
const navigationPromise = page.waitForURL(
|
||||
(url) => url.pathname === '/dashboard' || url.pathname === '/',
|
||||
{ timeout: 10000 }
|
||||
).catch(() => null);
|
||||
|
||||
const errorMessagePromise = page
|
||||
.waitForSelector('.bg-red-100, [role="alert"], .text-red-700', { timeout: 5000 })
|
||||
.catch(() => null);
|
||||
|
||||
await submitButton.click();
|
||||
|
||||
// Attendre soit la navigation, soit un message d'erreur
|
||||
const navigationResult = await navigationPromise;
|
||||
const errorElement = await errorMessagePromise;
|
||||
|
||||
if (navigationResult) {
|
||||
report.navigationSuccess = true;
|
||||
report.finalUrl = page.url();
|
||||
console.log('✅ [DIAGNOSTIC] Navigation réussie vers:', report.finalUrl);
|
||||
} else if (errorElement) {
|
||||
report.errorMessage = await errorElement.textContent() || 'Erreur inconnue';
|
||||
console.log('❌ [DIAGNOSTIC] Message d\'erreur détecté:', report.errorMessage);
|
||||
} else {
|
||||
// Attendre un peu plus pour voir si quelque chose se passe
|
||||
await page.waitForTimeout(2000);
|
||||
report.finalUrl = page.url();
|
||||
console.log('⚠️ [DIAGNOSTIC] Pas de navigation ni d\'erreur visible. URL actuelle:', report.finalUrl);
|
||||
}
|
||||
|
||||
// Étape 4: Vérifier le localStorage
|
||||
console.log('🔍 [DIAGNOSTIC] Vérification du localStorage...');
|
||||
const localStorageData = await context.storageState();
|
||||
const localStorageItems = await page.evaluate(() => {
|
||||
const items: Record<string, string> = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key) {
|
||||
items[key] = localStorage.getItem(key) || '';
|
||||
}
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
report.localStorage = localStorageItems;
|
||||
|
||||
// Vérifier spécifiquement les tokens
|
||||
const hasAccessToken = 'access_token' in localStorageItems ||
|
||||
'veza_access_token' in localStorageItems ||
|
||||
localStorageItems['access_token'] !== undefined ||
|
||||
localStorageItems['veza_access_token'] !== undefined;
|
||||
|
||||
console.log('📦 [DIAGNOSTIC] LocalStorage:', Object.keys(localStorageItems));
|
||||
console.log(hasAccessToken ? '✅ [DIAGNOSTIC] Token d\'accès présent' : '❌ [DIAGNOSTIC] Token d\'accès absent');
|
||||
|
||||
// Générer le rapport
|
||||
console.log('\n📊 [DIAGNOSTIC] === RAPPORT DE DIAGNOSTIC ===');
|
||||
console.log('Erreurs réseau:', report.networkErrors.length);
|
||||
console.log('Erreurs console:', report.consoleErrors.length);
|
||||
console.log('Erreurs CORS:', report.corsErrors.length);
|
||||
console.log('Navigation réussie:', report.navigationSuccess);
|
||||
console.log('Token présent:', hasAccessToken);
|
||||
|
||||
// Afficher les détails des erreurs
|
||||
if (report.networkErrors.length > 0) {
|
||||
console.log('\n🔴 Erreurs réseau:');
|
||||
report.networkErrors.forEach((err) => {
|
||||
console.log(` - ${err.method} ${err.url}: ${err.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (report.consoleErrors.length > 0) {
|
||||
console.log('\n🔴 Erreurs console:');
|
||||
report.consoleErrors.forEach((err) => {
|
||||
console.log(` - [${err.type}] ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (report.corsErrors.length > 0) {
|
||||
console.log('\n🟠 Erreurs CORS:');
|
||||
report.corsErrors.forEach((err) => {
|
||||
console.log(` - ${err.url}: ${err.reason}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Sauvegarder le rapport pour l'analyse
|
||||
await page.evaluate((report) => {
|
||||
(window as any).__diagnosticReport = report;
|
||||
}, report);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
// Récupérer le rapport depuis la page si disponible
|
||||
const savedReport = await page.evaluate(() => {
|
||||
return (window as any).__diagnosticReport;
|
||||
}).catch(() => null);
|
||||
|
||||
if (savedReport) {
|
||||
report = savedReport;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900 p-3">
|
||||
<h4 className="text-sm font-medium text-red-800 dark:text-red-200 mb-2">
|
||||
Détails de l'erreur :
|
||||
|
|
|
|||
|
|
@ -26,8 +26,10 @@ export function createLazyComponent<T extends ComponentType<any>>(
|
|||
export const LazyDashboard = createLazyComponent(() =>
|
||||
import('@/pages/DashboardPage').then((m) => ({ default: m.DashboardPage })),
|
||||
);
|
||||
export const LazyChat = createLazyComponent(
|
||||
() => import('@/features/chat/pages/ChatPage'),
|
||||
export const LazyChat = createLazyComponent(() =>
|
||||
import('@/features/chat/pages/ChatPage').then((m) => ({
|
||||
default: m.ChatPage,
|
||||
})),
|
||||
);
|
||||
export const LazyLibrary = createLazyComponent(
|
||||
() => import('@/features/library/pages/LibraryPage'),
|
||||
|
|
@ -35,8 +37,10 @@ export const LazyLibrary = createLazyComponent(
|
|||
export const LazyProfile = createLazyComponent(() =>
|
||||
import('@/pages/ProfilePage').then((m) => ({ default: m.ProfilePage })),
|
||||
);
|
||||
export const LazySettings = createLazyComponent(
|
||||
() => import('@/features/settings/pages/SettingsPage'),
|
||||
export const LazySettings = createLazyComponent(() =>
|
||||
import('@/features/settings/pages/SettingsPage').then((m) => ({
|
||||
default: m.SettingsPage,
|
||||
})),
|
||||
);
|
||||
export const LazyLogin = createLazyComponent(() =>
|
||||
import('@/pages/LoginPage').then((m) => ({ default: m.LoginPage })),
|
||||
|
|
@ -82,6 +86,8 @@ export const LazyPlaylistRoutes = createLazyComponent(() =>
|
|||
default: m.PlaylistRoutes,
|
||||
})),
|
||||
);
|
||||
export const LazyMarketplace = createLazyComponent(
|
||||
() => import('@/pages/marketplace/MarketplaceHome'),
|
||||
export const LazyMarketplace = createLazyComponent(() =>
|
||||
import('@/pages/marketplace/MarketplaceHome').then((m) => ({
|
||||
default: m.MarketplaceHome,
|
||||
})),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
|
||||
// URLs de l'API
|
||||
export const API_URLS = {
|
||||
BASE: process.env.VITE_API_URL || 'http://localhost:8080',
|
||||
WS: process.env.VITE_WS_URL || 'ws://localhost:8081',
|
||||
UPLOAD: process.env.VITE_UPLOAD_URL || 'http://localhost:8080/upload',
|
||||
STREAM: process.env.VITE_STREAM_URL || 'http://localhost:8082',
|
||||
BASE: import.meta.env.VITE_API_URL || 'http://127.0.0.1:8080',
|
||||
WS: import.meta.env.VITE_WS_URL || 'ws://127.0.0.1:8081',
|
||||
UPLOAD: import.meta.env.VITE_UPLOAD_URL || 'http://127.0.0.1:8080/upload',
|
||||
STREAM: import.meta.env.VITE_STREAM_URL || 'ws://127.0.0.1:8082',
|
||||
} as const;
|
||||
|
||||
// Endpoints de l'API
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { z } from 'zod';
|
|||
// Schéma de validation pour les variables d'environnement
|
||||
// Aligné avec FRONTEND_INTEGRATION.md
|
||||
const envSchema = z.object({
|
||||
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_API_URL: z.string().url().default('http://127.0.0.1:8080/api/v1'),
|
||||
VITE_WS_URL: z.string().url().default('ws://127.0.0.1:8081/ws'),
|
||||
VITE_STREAM_URL: z.string().url().default('ws://127.0.0.1:8082/stream'),
|
||||
VITE_UPLOAD_URL: z.string().url().default('http://127.0.0.1:8080/upload'),
|
||||
VITE_APP_NAME: z.string().default('Veza'),
|
||||
VITE_DEBUG: z
|
||||
.string()
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export const useChat = () => {
|
|||
|
||||
ws.current.onopen = () => {
|
||||
setWsStatus('connected');
|
||||
console.log('WebSocket connected');
|
||||
// WebSocket connection successful - no logging needed in production
|
||||
// Send any queued messages
|
||||
setMessagesToSend((prev) => {
|
||||
prev.forEach((msg) => ws.current?.send(JSON.stringify(msg)));
|
||||
|
|
@ -59,7 +59,7 @@ export const useChat = () => {
|
|||
|
||||
ws.current.onclose = () => {
|
||||
setWsStatus('disconnected');
|
||||
console.log('WebSocket disconnected');
|
||||
// WebSocket disconnected - no logging needed in production
|
||||
// Optional: Reconnect logic
|
||||
};
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ export const useChat = () => {
|
|||
!currentConversationId ||
|
||||
!userId
|
||||
) {
|
||||
console.warn('WebSocket not open, cannot send message');
|
||||
// WebSocket not ready - message will be queued
|
||||
// Queue message to send later
|
||||
setMessagesToSend((prev) => [
|
||||
...prev,
|
||||
|
|
|
|||
|
|
@ -109,7 +109,8 @@ export function LibraryManager({ onTrackSelect }: LibraryManagerProps) {
|
|||
if (originalTrack) {
|
||||
// setSelectedTrack(originalTrack);
|
||||
// setIsEditDialogOpen(true);
|
||||
console.log('Edit track', originalTrack); // Temporary
|
||||
// TODO: Implement edit track functionality
|
||||
// Removed temporary console.log for production
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export function PlayerError({
|
|||
</button>
|
||||
)}
|
||||
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
{import.meta.env.DEV && (
|
||||
<details className="w-full mt-2">
|
||||
<summary className="text-xs text-red-600 dark:text-red-400 cursor-pointer">
|
||||
Détails techniques
|
||||
|
|
|
|||
|
|
@ -62,11 +62,8 @@ export function useStreamSync(params: {
|
|||
|
||||
const targetTime = currentSec - correctionSec;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(
|
||||
`[Sync] Correcting drift: ${driftMs}ms. Seek ${currentSec} -> ${targetTime}`,
|
||||
);
|
||||
}
|
||||
// Stream sync correction - logging removed for production
|
||||
// Debug info available via onDebug callback if needed
|
||||
audioPlayerService.seek(targetTime);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,7 +7,15 @@ export function usePlaylistPermissions(playlist?: Playlist) {
|
|||
|
||||
return useMemo(() => {
|
||||
if (!playlist || !user) {
|
||||
return { canEdit: false, canDelete: false };
|
||||
return {
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
canAddTracks: false,
|
||||
canRemoveTracks: false,
|
||||
canManageCollaborators: false,
|
||||
canRead: false,
|
||||
isOwner: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isOwner = String(playlist.user_id) === String(user.id);
|
||||
|
|
@ -16,6 +24,11 @@ export function usePlaylistPermissions(playlist?: Playlist) {
|
|||
return {
|
||||
canEdit: isOwner,
|
||||
canDelete: isOwner,
|
||||
canAddTracks: isOwner,
|
||||
canRemoveTracks: isOwner,
|
||||
canManageCollaborators: isOwner,
|
||||
canRead: true, // Anyone can read public playlists, owner can read private ones
|
||||
isOwner,
|
||||
};
|
||||
}, [playlist, user]);
|
||||
}
|
||||
|
|
|
|||
127
apps/web/src/features/playlists/pages/PlaylistDetailPage.tsx
Normal file
127
apps/web/src/features/playlists/pages/PlaylistDetailPage.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { usePlaylist, useCollaborators } from '../hooks/usePlaylist';
|
||||
import { usePlaylistPermissions } from '../hooks/usePlaylistPermissions';
|
||||
import { PlaylistHeader } from '../components/PlaylistHeader';
|
||||
import { PlaylistActions } from '../components/PlaylistActions';
|
||||
import { PlaylistTrackList } from '../components/PlaylistTrackList';
|
||||
import { AddTrackToPlaylistModal } from '../components/AddTrackToPlaylistModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export function PlaylistDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [isAddTrackModalOpen, setIsAddTrackModalOpen] = useState(false);
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||||
|
||||
const {
|
||||
data: playlist,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = usePlaylist(id || '');
|
||||
|
||||
const { data: collaborators } = useCollaborators(id || '');
|
||||
|
||||
const permissions = usePlaylistPermissions(playlist);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !playlist) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-destructive mb-2">Error</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load playlist'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tracks = playlist.tracks?.map((pt) => pt.track).filter(Boolean) || [];
|
||||
const playlistTracks = playlist.tracks || [];
|
||||
|
||||
const handleTrackAdded = () => {
|
||||
setIsAddTrackModalOpen(false);
|
||||
refetch();
|
||||
toast.success('Track added to playlist');
|
||||
};
|
||||
|
||||
const handleTrackRemoved = () => {
|
||||
refetch();
|
||||
toast.success('Track removed from playlist');
|
||||
};
|
||||
|
||||
const handleTracksReordered = () => {
|
||||
refetch();
|
||||
toast.success('Playlist tracks reordered');
|
||||
};
|
||||
|
||||
const handleShareClick = () => {
|
||||
setIsShareModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<PlaylistHeader playlist={playlist} />
|
||||
|
||||
<div className="mb-6">
|
||||
<PlaylistActions
|
||||
playlist={playlist}
|
||||
onUpdated={refetch}
|
||||
onShareClick={handleShareClick}
|
||||
canShare={permissions.canRead}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold">Tracks</h2>
|
||||
{permissions.canAddTracks && (
|
||||
<Button
|
||||
onClick={() => setIsAddTrackModalOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Ajouter des tracks
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlaylistTrackList
|
||||
playlistTracks={playlistTracks}
|
||||
tracks={tracks}
|
||||
playlistId={Number(playlist.id)}
|
||||
onTrackRemoved={handleTrackRemoved}
|
||||
onTracksReordered={handleTracksReordered}
|
||||
enableDragAndDrop={permissions.canEdit}
|
||||
canRemoveTracks={permissions.canRemoveTracks}
|
||||
/>
|
||||
|
||||
<AddTrackToPlaylistModal
|
||||
open={isAddTrackModalOpen}
|
||||
onClose={() => setIsAddTrackModalOpen(false)}
|
||||
playlistId={playlist.id}
|
||||
onTracksAdded={handleTrackAdded}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
apps/web/src/features/playlists/routes.tsx
Normal file
15
apps/web/src/features/playlists/routes.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { PlaylistListPage } from './pages/PlaylistListPage';
|
||||
import { PlaylistDetailPage } from './pages/PlaylistDetailPage';
|
||||
|
||||
export function PlaylistRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<PlaylistListPage />} />
|
||||
<Route path="/new" element={<Navigate to="/playlists" replace />} />
|
||||
<Route path="/:id" element={<PlaylistDetailPage />} />
|
||||
<Route path="/:id/edit" element={<Navigate to="/playlists/:id" replace />} />
|
||||
<Route path="*" element={<Navigate to="/playlists" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
154
apps/web/src/features/profile/pages/UserProfilePage.tsx
Normal file
154
apps/web/src/features/profile/pages/UserProfilePage.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getProfileByUsername, type UserProfile } from '../services/profileService';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { FollowButton } from '../components/FollowButton';
|
||||
import { format } from 'date-fns';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export function UserProfilePage() {
|
||||
const { username } = useParams<{ username: string }>();
|
||||
|
||||
const {
|
||||
data: profile,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<UserProfile | null>({
|
||||
queryKey: ['userProfile', username],
|
||||
queryFn: async () => {
|
||||
if (!username) {
|
||||
throw new Error('Username is required');
|
||||
}
|
||||
return getProfileByUsername(username);
|
||||
},
|
||||
enabled: !!username,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner />
|
||||
<p className="mt-4 text-muted-foreground">Loading profile...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !username) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-destructive mb-2">Error</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: !username
|
||||
? 'Username is required'
|
||||
: 'Failed to load profile'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold mb-2">User Not Found</h2>
|
||||
<p className="text-muted-foreground">
|
||||
The user profile you're looking for doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName =
|
||||
profile.first_name || profile.last_name
|
||||
? `${profile.first_name || ''} ${profile.last_name || ''}`.trim()
|
||||
: profile.username;
|
||||
|
||||
const initials = displayName
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
|
||||
const memberSince = profile.created_at
|
||||
? format(new Date(profile.created_at), 'M/d/yyyy')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-24 w-24">
|
||||
<AvatarImage
|
||||
src={profile.avatar_url || undefined}
|
||||
alt={profile.username}
|
||||
/>
|
||||
<AvatarFallback className="text-2xl">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<CardTitle className="text-3xl mb-1">{profile.username}</CardTitle>
|
||||
{displayName !== profile.username && (
|
||||
<p className="text-xl text-muted-foreground">{displayName}</p>
|
||||
)}
|
||||
{memberSince && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Member since {memberSince}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FollowButton userId={profile.id.toString()} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{profile.bio && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Bio</h3>
|
||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
||||
{profile.bio}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.location && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Location</h3>
|
||||
<p className="text-muted-foreground">{profile.location}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!profile.bio && !profile.location && (
|
||||
<p className="text-muted-foreground italic">
|
||||
No additional information available.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
apps/web/src/features/roles/pages/RolesPage.tsx
Normal file
208
apps/web/src/features/roles/pages/RolesPage.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { getRoles, deleteRole, updateRole } from '../services/roleService';
|
||||
import type { Role } from '../types/role';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Shield, Edit, Trash2, Plus } from 'lucide-react';
|
||||
|
||||
export function RolesPage() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadRoles = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const loadedRoles = await getRoles();
|
||||
setRoles(loadedRoles);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to load roles';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles();
|
||||
}, []);
|
||||
|
||||
const handleToggleActive = async (role: Role) => {
|
||||
try {
|
||||
await updateRole(role.id, { is_active: !role.is_active });
|
||||
toast.success(
|
||||
`Role ${!role.is_active ? 'activated' : 'deactivated'} successfully`,
|
||||
);
|
||||
loadRoles();
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to update role';
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (role: Role) => {
|
||||
if (role.is_system) {
|
||||
toast.error('Cannot delete system roles');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete role "${role.display_name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteRole(role.id);
|
||||
toast.success('Role deleted successfully');
|
||||
loadRoles();
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to delete role';
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-destructive mb-2">Error</h2>
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
<Button onClick={loadRoles} className="mt-4" variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Roles Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage user roles and permissions
|
||||
</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Role
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Roles
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{roles.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No roles found</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Display Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Permissions</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell className="font-medium">{role.name}</TableCell>
|
||||
<TableCell>{role.display_name}</TableCell>
|
||||
<TableCell className="max-w-md truncate">
|
||||
{role.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={role.is_active ? 'success' : 'warning'}
|
||||
>
|
||||
{role.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{role.is_system ? (
|
||||
<Badge variant="primary">System</Badge>
|
||||
) : (
|
||||
<Badge variant="default">Custom</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{role.permissions?.length || 0} permissions
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleToggleActive(role)}
|
||||
disabled={role.is_system}
|
||||
>
|
||||
{role.is_active ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={role.is_system}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(role)}
|
||||
disabled={role.is_system}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
apps/web/src/features/settings/components/PreferenceSettings.tsx
Normal file
137
apps/web/src/features/settings/components/PreferenceSettings.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from '@/components/ui/radio-group';
|
||||
import { PreferenceSettings as PreferenceSettingsType } from '../types/settings';
|
||||
|
||||
interface PreferenceSettingsProps {
|
||||
preferences: PreferenceSettingsType;
|
||||
onChange: (preferences: PreferenceSettingsType) => void;
|
||||
}
|
||||
|
||||
const supportedLanguages = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
];
|
||||
|
||||
const commonTimezones = [
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
{ value: 'Europe/Paris', label: 'Europe/Paris' },
|
||||
{ value: 'America/New_York', label: 'America/New_York' },
|
||||
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles' },
|
||||
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo' },
|
||||
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai' },
|
||||
];
|
||||
|
||||
export function PreferenceSettings({
|
||||
preferences,
|
||||
onChange,
|
||||
}: PreferenceSettingsProps) {
|
||||
const handleLanguageChange = (value: string) => {
|
||||
onChange({
|
||||
...preferences,
|
||||
language: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimezoneChange = (value: string) => {
|
||||
onChange({
|
||||
...preferences,
|
||||
timezone: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleThemeChange = (value: string) => {
|
||||
onChange({
|
||||
...preferences,
|
||||
theme: value as 'light' | 'dark' | 'auto',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language">Langue</Label>
|
||||
<Select
|
||||
value={preferences.language}
|
||||
onValueChange={handleLanguageChange}
|
||||
>
|
||||
<SelectTrigger id="language">
|
||||
<SelectValue placeholder="Sélectionner une langue" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedLanguages.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">Fuseau horaire</Label>
|
||||
<Select
|
||||
value={preferences.timezone}
|
||||
onValueChange={handleTimezoneChange}
|
||||
>
|
||||
<SelectTrigger id="timezone">
|
||||
<SelectValue placeholder="Sélectionner un fuseau horaire" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{commonTimezones.map((tz) => (
|
||||
<SelectItem key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Thème</Label>
|
||||
<RadioGroup
|
||||
value={preferences.theme}
|
||||
onValueChange={handleThemeChange}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="light" id="theme-light" />
|
||||
<Label htmlFor="theme-light" className="font-normal">
|
||||
Clair
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="dark" id="theme-dark" />
|
||||
<Label htmlFor="theme-dark" className="font-normal">
|
||||
Sombre
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto" id="theme-auto" />
|
||||
<Label htmlFor="theme-auto" className="font-normal">
|
||||
Automatique
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
apps/web/src/features/settings/components/SettingsTabs.tsx
Normal file
82
apps/web/src/features/settings/components/SettingsTabs.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { UserSettings } from '../types/settings';
|
||||
import { PreferenceSettings } from './PreferenceSettings';
|
||||
import { NotificationSettings } from './NotificationSettings';
|
||||
import { PrivacySettings } from './PrivacySettings';
|
||||
import { ContentSettings } from './ContentSettings';
|
||||
|
||||
interface SettingsTabsProps {
|
||||
settings: UserSettings;
|
||||
onChange: (settings: UserSettings) => void;
|
||||
}
|
||||
|
||||
export function SettingsTabs({ settings, onChange }: SettingsTabsProps) {
|
||||
const handlePreferencesChange = (preferences: typeof settings.preferences) => {
|
||||
onChange({
|
||||
...settings,
|
||||
preferences,
|
||||
});
|
||||
};
|
||||
|
||||
const handleNotificationsChange = (
|
||||
notifications: typeof settings.notifications,
|
||||
) => {
|
||||
onChange({
|
||||
...settings,
|
||||
notifications,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePrivacyChange = (privacy: typeof settings.privacy) => {
|
||||
onChange({
|
||||
...settings,
|
||||
privacy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContentChange = (content: typeof settings.content) => {
|
||||
onChange({
|
||||
...settings,
|
||||
content,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="preferences" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="preferences">Préférences</TabsTrigger>
|
||||
<TabsTrigger value="notifications">Notifications</TabsTrigger>
|
||||
<TabsTrigger value="privacy">Confidentialité</TabsTrigger>
|
||||
<TabsTrigger value="content">Contenu</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="preferences" className="mt-6">
|
||||
<PreferenceSettings
|
||||
preferences={settings.preferences}
|
||||
onChange={handlePreferencesChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notifications" className="mt-6">
|
||||
<NotificationSettings
|
||||
notifications={settings.notifications}
|
||||
onChange={handleNotificationsChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="privacy" className="mt-6">
|
||||
<PrivacySettings
|
||||
privacy={settings.privacy}
|
||||
onChange={handlePrivacyChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="content" className="mt-6">
|
||||
<ContentSettings
|
||||
content={settings.content}
|
||||
onChange={handleContentChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
130
apps/web/src/features/settings/pages/SettingsPage.tsx
Normal file
130
apps/web/src/features/settings/pages/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { getSettings, updateSettings } from '../services/settingsService';
|
||||
import { UserSettings } from '../types/settings';
|
||||
import { SettingsTabs } from '../components/SettingsTabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { settingsSchema } from '../schemas/settingsSchema';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { user } = useAuthStore();
|
||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) {
|
||||
setError('Vous devez être connecté pour accéder aux paramètres');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const userSettings = await getSettings(user.id);
|
||||
setSettings(userSettings);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Erreur de chargement des paramètres';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, [user?.id]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!user?.id || !settings) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Valider les settings avec le schéma Zod
|
||||
const validationResult = settingsSchema.safeParse(settings);
|
||||
if (!validationResult.success) {
|
||||
const errors = validationResult.error.errors
|
||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join(', ');
|
||||
toast.error(`Erreur de validation: ${errors}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await updateSettings(user.id, settings);
|
||||
toast.success('Paramètres sauvegardés avec succès');
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Erreur lors de la sauvegarde';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !settings) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-4">Paramètres</h1>
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold mb-2">Paramètres</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gérez vos paramètres de compte et préférences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-lg border p-6">
|
||||
<SettingsTabs settings={settings} onChange={setSettings} />
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<LoadingSpinner className="mr-2 h-4 w-4" />
|
||||
Sauvegarde...
|
||||
</>
|
||||
) : (
|
||||
'Sauvegarder'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -144,7 +144,17 @@ export function usePlaybackRealtime(
|
|||
* Construit l'URL WebSocket pour le track
|
||||
*/
|
||||
const getWebSocketUrl = useCallback((trackId: number): string => {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
const apiBaseUrl = (() => {
|
||||
const url = import.meta.env.VITE_API_URL;
|
||||
if (!url) {
|
||||
if (import.meta.env.PROD) {
|
||||
throw new Error('VITE_API_URL must be defined in production');
|
||||
}
|
||||
// Fallback uniquement en développement
|
||||
return 'http://127.0.0.1:8080';
|
||||
}
|
||||
return url;
|
||||
})();
|
||||
// Convertir http:// en ws:// ou https:// en wss://
|
||||
const wsBaseUrl = apiBaseUrl.replace(/^http/, 'ws');
|
||||
// Récupérer le token d'authentification depuis le client API
|
||||
|
|
|
|||
262
apps/web/src/features/tracks/pages/TrackDetailPage.tsx
Normal file
262
apps/web/src/features/tracks/pages/TrackDetailPage.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getTrack, TrackUploadError } from '../services/trackService';
|
||||
import { usePlayerStore } from '@/features/player/store/playerStore';
|
||||
import type { Track as PlayerTrack } from '@/features/player/types';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Play, Pause, ArrowLeft, Share2, Plus } from 'lucide-react';
|
||||
import { Music } from 'lucide-react';
|
||||
import type { Track } from '../types/track';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function TrackDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [track, setTrack] = useState<Track | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { play, pause, currentTrack, isPlaying, addToQueue } = usePlayerStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setError('Track ID is required');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadTrack = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const trackId = Number(id);
|
||||
if (isNaN(trackId)) {
|
||||
throw new Error('Invalid track ID');
|
||||
}
|
||||
const loadedTrack = await getTrack(trackId);
|
||||
setTrack(loadedTrack);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof TrackUploadError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to load track';
|
||||
setError(errorMessage);
|
||||
if (err instanceof TrackUploadError) {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTrack();
|
||||
}, [id]);
|
||||
|
||||
const mapToPlayerTrack = (t: Track): PlayerTrack => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
album: t.album,
|
||||
duration: t.duration,
|
||||
url: t.stream_manifest_url || t.file_path,
|
||||
cover: t.cover_art_path,
|
||||
genre: t.genre,
|
||||
});
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!track) return;
|
||||
const playerTrack = mapToPlayerTrack(track);
|
||||
play(playerTrack);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
pause();
|
||||
};
|
||||
|
||||
const handleAddToQueue = () => {
|
||||
if (!track) return;
|
||||
const playerTrack = mapToPlayerTrack(track);
|
||||
addToQueue([playerTrack]);
|
||||
toast.success('Track ajouté à la file d\'attente');
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!track) return;
|
||||
const shareUrl = `${window.location.origin}/tracks/${track.id}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast.success('Lien copié dans le presse-papiers');
|
||||
} catch (err) {
|
||||
toast.error('Impossible de copier le lien');
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentTrack = currentTrack?.id === track?.id;
|
||||
const isCurrentlyPlaying = isCurrentTrack && isPlaying;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<LoadingSpinner />
|
||||
<span className="ml-2 text-muted-foreground">Chargement du track...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !track) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-destructive mb-2">Error</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{error || 'Track introuvable'}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => navigate(-1)}
|
||||
className="mt-4"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Retour
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Button
|
||||
onClick={() => navigate(-1)}
|
||||
variant="ghost"
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Retour
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Cover Art */}
|
||||
<div className="md:col-span-1">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
{track.cover_art_path ? (
|
||||
<img
|
||||
src={track.cover_art_path}
|
||||
alt={track.title}
|
||||
className="w-full aspect-square object-cover rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full aspect-square bg-muted rounded-lg flex items-center justify-center">
|
||||
<Music className="h-24 w-24 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Track Details */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<h1 className="text-3xl font-bold mb-2">{track.title}</h1>
|
||||
<p className="text-xl text-muted-foreground mb-4">{track.artist}</p>
|
||||
{track.album && (
|
||||
<p className="text-lg mb-4">Album: {track.album}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
{isCurrentlyPlaying ? (
|
||||
<Button onClick={handlePause} size="lg">
|
||||
<Pause className="h-5 w-5 mr-2" />
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handlePlay} size="lg">
|
||||
<Play className="h-5 w-5 mr-2" />
|
||||
Play
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleAddToQueue}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
title="Ajouter à la file d'attente"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Queue
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
title="Partager"
|
||||
>
|
||||
<Share2 className="h-5 w-5 mr-2" />
|
||||
Partager
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Durée</p>
|
||||
<p className="font-semibold">{formatDuration(track.duration)}</p>
|
||||
</div>
|
||||
{track.genre && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Genre</p>
|
||||
<p className="font-semibold">{track.genre}</p>
|
||||
</div>
|
||||
)}
|
||||
{track.year && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Année</p>
|
||||
<p className="font-semibold">{track.year}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-muted-foreground">Lectures</p>
|
||||
<p className="font-semibold">{track.play_count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Likes</p>
|
||||
<p className="font-semibold">{track.like_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Waveform */}
|
||||
{track.waveform_path && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<h2 className="text-xl font-bold mb-4">Waveform</h2>
|
||||
<img
|
||||
src={track.waveform_path}
|
||||
alt="Waveform"
|
||||
className="w-full h-32 object-contain"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||
import { Toaster } from 'react-hot-toast';
|
||||
import { App } from './app/App';
|
||||
import './index.css';
|
||||
// Initialize i18next before React renders
|
||||
import './lib/i18n';
|
||||
|
||||
// HMR Force Update: 1765126900
|
||||
|
||||
|
|
@ -20,7 +22,12 @@ const queryClient = new QueryClient({
|
|||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ export function DashboardPage() {
|
|||
fetchItems({ limit: 5 });
|
||||
}, [fetchItems]);
|
||||
|
||||
// Sécuriser items pour s'assurer que c'est toujours un tableau
|
||||
const safeItems = Array.isArray(items) ? items : [];
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: 'Pistes écoutées',
|
||||
|
|
@ -152,7 +155,7 @@ export function DashboardPage() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{items.slice(0, 3).map((item) => (
|
||||
{safeItems.slice(0, 3).map((item) => (
|
||||
<div key={item.id} className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-muted rounded flex items-center justify-center">
|
||||
<Music className="h-4 w-4 text-muted-foreground" />
|
||||
|
|
@ -167,7 +170,7 @@ export function DashboardPage() {
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
{safeItems.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Aucune piste dans votre bibliothèque
|
||||
</p>
|
||||
|
|
|
|||
92
apps/web/src/pages/marketplace/MarketplaceHome.tsx
Normal file
92
apps/web/src/pages/marketplace/MarketplaceHome.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { marketplaceService } from '@/services/marketplaceService';
|
||||
import { ProductCard } from '@/features/marketplace/components/ProductCard';
|
||||
import { Product } from '@/types/marketplace';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export function MarketplaceHome() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [purchasingProductId, setPurchasingProductId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadProducts = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const fetchedProducts = await marketplaceService.fetchProducts();
|
||||
setProducts(fetchedProducts);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load marketplace products';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProducts();
|
||||
}, []);
|
||||
|
||||
const handlePurchase = async (product: Product) => {
|
||||
try {
|
||||
setPurchasingProductId(product.id);
|
||||
await marketplaceService.purchaseProduct(product.id);
|
||||
toast.success(`Successfully purchased ${product.title}`);
|
||||
// Optionally refresh products or update UI
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to purchase product';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setPurchasingProductId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold mb-2">Marketplace</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Discover and purchase music products, samples, and licenses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
No products available at the moment.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onPurchase={handlePurchase}
|
||||
isPurchasing={purchasingProductId === product.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { useAuth } from '@/features/auth/hooks/useAuth';
|
|||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { DashboardLayout } from '@/components/layout/DashboardLayout';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import {
|
||||
LazyLogin,
|
||||
LazyRegister,
|
||||
|
|
@ -126,7 +127,9 @@ export const AppRouter = () => (
|
|||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedLayoutRoute>
|
||||
<LazyLibrary />
|
||||
<ErrorBoundary>
|
||||
<LazyLibrary />
|
||||
</ErrorBoundary>
|
||||
</ProtectedLayoutRoute>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
|
@ -136,7 +139,9 @@ export const AppRouter = () => (
|
|||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedLayoutRoute>
|
||||
<LazyProfile />
|
||||
<ErrorBoundary>
|
||||
<LazyProfile />
|
||||
</ErrorBoundary>
|
||||
</ProtectedLayoutRoute>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
|
@ -146,7 +151,9 @@ export const AppRouter = () => (
|
|||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedLayoutRoute>
|
||||
<LazySettings />
|
||||
<ErrorBoundary>
|
||||
<LazySettings />
|
||||
</ErrorBoundary>
|
||||
</ProtectedLayoutRoute>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,30 @@ import type {
|
|||
export type { Track };
|
||||
|
||||
// Configuration de base
|
||||
const API_BASE_URL =
|
||||
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';
|
||||
// En production, les variables d'environnement doivent être définies
|
||||
const API_BASE_URL = (() => {
|
||||
const url = import.meta.env.VITE_API_URL;
|
||||
if (!url) {
|
||||
if (import.meta.env.PROD) {
|
||||
throw new Error('VITE_API_URL must be defined in production');
|
||||
}
|
||||
// Fallback uniquement en développement
|
||||
return 'http://127.0.0.1:8080/api/v1';
|
||||
}
|
||||
return url;
|
||||
})();
|
||||
|
||||
const WS_BASE_URL = (() => {
|
||||
const url = import.meta.env.VITE_WS_URL;
|
||||
if (!url) {
|
||||
if (import.meta.env.PROD) {
|
||||
throw new Error('VITE_WS_URL must be defined in production');
|
||||
}
|
||||
// Fallback uniquement en développement
|
||||
return 'ws://127.0.0.1:8081/ws';
|
||||
}
|
||||
return url;
|
||||
})();
|
||||
|
||||
// Schémas de validation Zod
|
||||
const UserSchema = z.object({
|
||||
|
|
@ -270,12 +291,14 @@ export class ApiService {
|
|||
}
|
||||
|
||||
// Méthodes pour la bibliothèque
|
||||
// Note: Le backend n'a pas d'endpoint /library, on utilise /tracks à la place
|
||||
async getLibraryItems(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
type?: string;
|
||||
}): Promise<PaginatedResponse<LibraryItem>> {
|
||||
const response = await this.client.get('/library', { params });
|
||||
// Utiliser /tracks au lieu de /library qui n'existe pas
|
||||
const response = await this.client.get('/tracks', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,8 +49,17 @@ const AuthTokensSchema = z.object({
|
|||
});
|
||||
|
||||
// Configuration
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const API_BASE_URL = (() => {
|
||||
const url = import.meta.env.VITE_API_URL;
|
||||
if (!url) {
|
||||
if (import.meta.env.PROD) {
|
||||
throw new Error('VITE_API_URL must be defined in production');
|
||||
}
|
||||
// Fallback uniquement en développement
|
||||
return 'http://127.0.0.1:8080/api/v1';
|
||||
}
|
||||
return url;
|
||||
})();
|
||||
|
||||
export class SecureAuthService {
|
||||
private static instance: SecureAuthService;
|
||||
|
|
|
|||
|
|
@ -7,8 +7,20 @@ let refreshClient: AxiosInstance | null = null;
|
|||
|
||||
function getRefreshClient(): AxiosInstance {
|
||||
if (!refreshClient) {
|
||||
const baseURL = (() => {
|
||||
const url = import.meta.env.VITE_API_URL;
|
||||
if (!url) {
|
||||
if (import.meta.env.PROD) {
|
||||
throw new Error('VITE_API_URL must be defined in production');
|
||||
}
|
||||
// Fallback uniquement en développement
|
||||
return 'http://127.0.0.1:8080/api/v1';
|
||||
}
|
||||
return url;
|
||||
})();
|
||||
|
||||
refreshClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1',
|
||||
baseURL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
|
|
@ -44,7 +44,17 @@ class WebSocketServiceImpl implements WebSocketService {
|
|||
}
|
||||
|
||||
// CORRECTION: Le serveur Rust expose le WebSocket sur /ws
|
||||
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:8081/ws';
|
||||
const wsUrl = (() => {
|
||||
const url = import.meta.env.VITE_WS_URL;
|
||||
if (!url) {
|
||||
if (import.meta.env.PROD) {
|
||||
throw new Error('VITE_WS_URL must be defined in production');
|
||||
}
|
||||
// Fallback uniquement en développement
|
||||
return 'ws://127.0.0.1:8081/ws';
|
||||
}
|
||||
return url;
|
||||
})();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -73,20 +73,29 @@ export const useLibraryStore = create<LibraryState & LibraryActions>(
|
|||
type,
|
||||
});
|
||||
|
||||
// Sécuriser response.data pour s'assurer que c'est toujours un tableau
|
||||
const itemsArray = Array.isArray(response.data)
|
||||
? response.data
|
||||
: Array.isArray(response)
|
||||
? response
|
||||
: [];
|
||||
|
||||
set({
|
||||
items: response.data,
|
||||
items: itemsArray,
|
||||
pagination: {
|
||||
page: response.page,
|
||||
limit: response.limit,
|
||||
total: response.total,
|
||||
hasNext: response.has_next,
|
||||
hasPrev: response.has_prev,
|
||||
page: response.page || 1,
|
||||
limit: response.limit || limit,
|
||||
total: response.total || 0,
|
||||
hasNext: response.has_next || false,
|
||||
hasPrev: response.has_prev || false,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// En cas d'erreur, s'assurer que items reste un tableau vide
|
||||
set({
|
||||
items: [],
|
||||
error: error as ApiError,
|
||||
isLoading: false,
|
||||
});
|
||||
|
|
@ -103,13 +112,22 @@ export const useLibraryStore = create<LibraryState & LibraryActions>(
|
|||
type: 'favorites',
|
||||
});
|
||||
|
||||
// Sécuriser response.data pour s'assurer que c'est toujours un tableau
|
||||
const favoritesArray = Array.isArray(response.data)
|
||||
? response.data
|
||||
: Array.isArray(response)
|
||||
? response
|
||||
: [];
|
||||
|
||||
set({
|
||||
favorites: response.data,
|
||||
favorites: favoritesArray,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// En cas d'erreur, s'assurer que favorites reste un tableau vide
|
||||
set({
|
||||
favorites: [],
|
||||
error: error as ApiError,
|
||||
isLoading: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export function createCSPMiddleware() {
|
|||
setCSPNonce(nonce);
|
||||
|
||||
const cspHeader =
|
||||
process.env.NODE_ENV === 'production'
|
||||
import.meta.env.MODE === 'production'
|
||||
? buildCSPHeader(nonce)
|
||||
: buildCSPHeaderDev();
|
||||
|
||||
|
|
|
|||
50
apps/web/src/utils/logger.ts
Normal file
50
apps/web/src/utils/logger.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Logger conditionnel pour la production
|
||||
* Remplace console.log/info/warn/error par des fonctions conditionnelles
|
||||
* En production, seuls les erreurs critiques sont loggées
|
||||
*/
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface Logger {
|
||||
debug: (...args: unknown[]) => void;
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
const isProd = import.meta.env.PROD;
|
||||
|
||||
/**
|
||||
* Logger conditionnel qui filtre les logs selon l'environnement
|
||||
*/
|
||||
export const logger: Logger = {
|
||||
debug: (...args: unknown[]) => {
|
||||
if (isDev) {
|
||||
console.debug('[DEBUG]', ...args);
|
||||
}
|
||||
},
|
||||
info: (...args: unknown[]) => {
|
||||
if (isDev) {
|
||||
console.info('[INFO]', ...args);
|
||||
}
|
||||
},
|
||||
warn: (...args: unknown[]) => {
|
||||
// Warnings toujours loggés, mais avec préfixe en prod
|
||||
if (isProd) {
|
||||
console.warn('[WARN]', ...args);
|
||||
} else {
|
||||
console.warn('[WARN]', ...args);
|
||||
}
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
// Erreurs toujours loggées en production pour le debugging
|
||||
console.error('[ERROR]', ...args);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Export par défaut pour faciliter l'import
|
||||
*/
|
||||
export default logger;
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
* XSS Protection utilities
|
||||
* Sanitise et valide le contenu utilisateur pour prévenir les attaques XSS
|
||||
*/
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Types pour la configuration de sanitisation
|
||||
export interface SanitizeOptions {
|
||||
|
|
@ -268,7 +269,25 @@ function escapeHTML(content: string): string {
|
|||
/**
|
||||
* Sanitise spécifiquement les messages de chat
|
||||
*/
|
||||
/**
|
||||
* Sanitise les messages de chat avec DOMPurify pour une protection XSS robuste
|
||||
* Utilise DOMPurify en priorité, avec fallback sur sanitizeHTML si DOMPurify n'est pas disponible
|
||||
*/
|
||||
export function sanitizeChatMessage(message: string): string {
|
||||
// Utiliser DOMPurify pour une sanitisation robuste et éprouvée
|
||||
if (typeof window !== 'undefined' && DOMPurify.isSupported) {
|
||||
return DOMPurify.sanitize(message, {
|
||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'i', 'b', 'span', 'a'],
|
||||
ALLOWED_ATTR: ['class', 'href', 'title', 'target'],
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
||||
KEEP_CONTENT: true,
|
||||
RETURN_DOM: false,
|
||||
RETURN_DOM_FRAGMENT: false,
|
||||
RETURN_TRUSTED_TYPE: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback sur la sanitisation manuelle si DOMPurify n'est pas disponible (SSR)
|
||||
const chatOptions: SanitizeOptions = {
|
||||
allowedTags: ['p', 'br', 'strong', 'em', 'u', 'i', 'b', 'span'],
|
||||
allowedAttributes: {
|
||||
|
|
|
|||
|
|
@ -59,20 +59,36 @@ export default defineConfig(({ mode }) => {
|
|||
port: 3000,
|
||||
host: true,
|
||||
headers: {
|
||||
'Content-Security-Policy': [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:",
|
||||
"worker-src 'self' blob:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https: blob:",
|
||||
"connect-src 'self' ws: wss: http: https:",
|
||||
"font-src 'self' data:",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"upgrade-insecure-requests"
|
||||
].join('; ')
|
||||
'Content-Security-Policy': (() => {
|
||||
const basePolicy = [
|
||||
"default-src 'self'",
|
||||
"worker-src 'self' blob:",
|
||||
"img-src 'self' data: https: blob:",
|
||||
"connect-src 'self' ws: wss: http: https:",
|
||||
"font-src 'self' data:",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"upgrade-insecure-requests"
|
||||
];
|
||||
|
||||
// Production: Remove unsafe-inline and unsafe-eval, use nonces
|
||||
// Dev: Keep unsafe-inline for Vite HMR, but remove unsafe-eval
|
||||
if (isProduction) {
|
||||
basePolicy.push(
|
||||
"script-src 'self' 'nonce-__CSP_NONCE__' blob:",
|
||||
"style-src 'self' 'nonce-__CSP_NONCE__'"
|
||||
);
|
||||
} else {
|
||||
basePolicy.push(
|
||||
"script-src 'self' 'unsafe-inline' blob:",
|
||||
"style-src 'self' 'unsafe-inline'"
|
||||
);
|
||||
}
|
||||
|
||||
return basePolicy.join('; ');
|
||||
})()
|
||||
}
|
||||
},
|
||||
build: {
|
||||
|
|
|
|||
18
e2e/example.spec.ts
Normal file
18
e2e/example.spec.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('has title', async ({ page }) => {
|
||||
await page.goto('https://playwright.dev/');
|
||||
|
||||
// Expect a title "to contain" a substring.
|
||||
await expect(page).toHaveTitle(/Playwright/);
|
||||
});
|
||||
|
||||
test('get started link', async ({ page }) => {
|
||||
await page.goto('https://playwright.dev/');
|
||||
|
||||
// Click the get started link.
|
||||
await page.getByRole('link', { name: 'Get started' }).click();
|
||||
|
||||
// Expects page to have a heading with the name of Installation.
|
||||
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||||
});
|
||||
15
e2e/test-1.spec.ts
Normal file
15
e2e/test-1.spec.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/login');
|
||||
await page.getByRole('link', { name: 'S\'inscrire' }).click();
|
||||
await page.locator('input[name="email"]').click();
|
||||
await page.locator('input[name="email"]').fill('test@free.fr');
|
||||
await page.locator('input[name="email"]').press('Tab');
|
||||
await page.locator('input[name="username"]').fill('tester');
|
||||
await page.locator('input[name="username"]').press('Tab');
|
||||
await page.locator('input[name="password"]').fill('Test12345678!');
|
||||
await page.locator('input[name="password_confirm"]').click();
|
||||
await page.locator('input[name="password_confirm"]').fill('Test12345678!');
|
||||
await page.getByRole('button', { name: 'S\'inscrire' }).click();
|
||||
});
|
||||
3506
package-lock.json
generated
Normal file
3506
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +1,14 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@types/node": "^25.0.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^16.5.0",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.3"
|
||||
}
|
||||
},
|
||||
"scripts": {}
|
||||
}
|
||||
|
|
|
|||
79
playwright.config.ts
Normal file
79
playwright.config.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// url: 'http://localhost:3000',
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
});
|
||||
109
veza-backend-api/cmd/tools/create_test_user/main.go
Normal file
109
veza-backend-api/cmd/tools/create_test_user/main.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load .env file
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Printf("Note: .env file not found, using system environment variables")
|
||||
}
|
||||
|
||||
// Get database connection string
|
||||
databaseURL := os.Getenv("DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
// Fallback to individual components
|
||||
dbHost := getEnv("DB_HOST", "localhost")
|
||||
dbPort := getEnv("DB_PORT", "5432")
|
||||
dbUser := getEnv("DB_USER", "veza")
|
||||
dbPassword := getEnv("DB_PASSWORD", "password")
|
||||
dbName := getEnv("DB_NAME", "veza")
|
||||
databaseURL = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
dbHost, dbPort, dbUser, dbPassword, dbName)
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
// Get test user credentials from environment or use defaults
|
||||
email := getEnv("TEST_EMAIL", "user@example.com")
|
||||
password := getEnv("TEST_PASSWORD", "password123")
|
||||
username := getEnv("TEST_USERNAME", "testuser")
|
||||
|
||||
// Check if user already exists
|
||||
var existingUser models.User
|
||||
result := db.Where("email = ?", email).First(&existingUser)
|
||||
if result.Error == nil {
|
||||
log.Printf("User with email %s already exists (ID: %s)", email, existingUser.ID)
|
||||
|
||||
// Update password if needed
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
existingUser.PasswordHash = string(hashedPassword)
|
||||
existingUser.IsVerified = true
|
||||
existingUser.IsActive = true
|
||||
|
||||
if err := db.Save(&existingUser).Error; err != nil {
|
||||
log.Fatalf("Failed to update user: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Updated existing user: %s (password reset, verified and active)", email)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
// Generate slug from username
|
||||
slug := strings.ToLower(strings.ReplaceAll(username, "_", "-"))
|
||||
|
||||
// Create user
|
||||
user := &models.User{
|
||||
Email: email,
|
||||
Username: username,
|
||||
Slug: slug,
|
||||
PasswordHash: string(hashedPassword),
|
||||
IsVerified: true,
|
||||
IsActive: true,
|
||||
Role: "user",
|
||||
FirstName: "Test",
|
||||
LastName: "User",
|
||||
}
|
||||
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
log.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Created test user successfully!")
|
||||
log.Printf(" Email: %s", email)
|
||||
log.Printf(" Username: %s", username)
|
||||
log.Printf(" Password: %s", password)
|
||||
log.Printf(" ID: %s", user.ID)
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
-- Migration to cleanup refresh_tokens table
|
||||
-- Remove legacy column 'token' which caused NULL constraint violations
|
||||
-- Ensure correct constraints on token_hash
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Remove the legacy 'token' column which is no longer used by the application
|
||||
-- The application now uses 'token_hash' for secure storage
|
||||
ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS token;
|
||||
|
||||
-- 2. Ensure token_hash has the correct constraints
|
||||
-- It should be NOT NULL and UNIQUE to prevent duplicates and ensure integrity
|
||||
ALTER TABLE refresh_tokens ALTER COLUMN token_hash SET NOT NULL;
|
||||
|
||||
-- 3. Add comment to clarify the column usage
|
||||
COMMENT ON COLUMN refresh_tokens.token_hash IS 'SHA-256 hash of the refresh token. The raw token is never stored.';
|
||||
|
||||
COMMIT;
|
||||
49
veza-backend-api/migrations/011_cleanup_refresh_tokens.sql
Normal file
49
veza-backend-api/migrations/011_cleanup_refresh_tokens.sql
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
-- Migration to cleanup refresh_tokens table
|
||||
-- Remove legacy column 'token' which caused NULL constraint violations
|
||||
-- Ensure correct constraints on token_hash
|
||||
-- This migration runs AFTER 010_auth_and_users.sql which creates the refresh_tokens table
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Check if the table exists before attempting to alter it
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Only proceed if the refresh_tokens table exists
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'refresh_tokens'
|
||||
) THEN
|
||||
-- 1. Remove the legacy 'token' column which is no longer used by the application
|
||||
-- The application now uses 'token_hash' for secure storage
|
||||
ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS token;
|
||||
|
||||
-- 2. Ensure token_hash has the correct constraints
|
||||
-- It should be NOT NULL and UNIQUE to prevent duplicates and ensure integrity
|
||||
-- Only set NOT NULL if the column exists and doesn't already have the constraint
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'refresh_tokens'
|
||||
AND column_name = 'token_hash'
|
||||
) THEN
|
||||
-- Check if column is already NOT NULL
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'refresh_tokens'
|
||||
AND column_name = 'token_hash'
|
||||
AND is_nullable = 'YES'
|
||||
) THEN
|
||||
ALTER TABLE refresh_tokens ALTER COLUMN token_hash SET NOT NULL;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- 3. Add comment to clarify the column usage
|
||||
COMMENT ON COLUMN refresh_tokens.token_hash IS 'SHA-256 hash of the refresh token. The raw token is never stored.';
|
||||
ELSE
|
||||
RAISE NOTICE 'Table refresh_tokens does not exist yet. Skipping cleanup migration.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
Loading…
Reference in a new issue