stabilizing apps/web: FIRST BATCH

This commit is contained in:
senke 2025-12-17 08:07:35 -05:00
parent 4b5003bdbe
commit 3d72d5ac3c
49 changed files with 7049 additions and 82 deletions

27
.github/workflows/playwright.yml vendored Normal file
View 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
View file

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

View 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.

View 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

View 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

View 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
View 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
}
}

View 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');
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View 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;
}
});
});

View file

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

View file

@ -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,
})),
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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);
}
},

View file

@ -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]);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

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

View 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>
);
}

View file

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

View file

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

View 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>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
});

View file

@ -159,7 +159,7 @@ export function createCSPMiddleware() {
setCSPNonce(nonce);
const cspHeader =
process.env.NODE_ENV === 'production'
import.meta.env.MODE === 'production'
? buildCSPHeader(nonce)
: buildCSPHeaderDev();

View 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;

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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
View 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,
// },
});

View 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
}

View file

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

View 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;