Initial commit
This commit is contained in:
@@ -0,0 +1,926 @@
|
||||
# User Story: Authentification Utilisateur
|
||||
|
||||
**ID** : US-001
|
||||
**Epic** : Epic 4 - User Authentication & Access Control
|
||||
**Status** : Draft
|
||||
**Priorité** : P0 - Critique (bloque toutes les autres fonctionnalités)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Description
|
||||
|
||||
En tant qu'utilisateur, je veux créer un compte sur Chartbastan pour accéder au dashboard et consulter les prédictions de matchs.
|
||||
|
||||
## 🎯 Objectifs d'Utilisateur
|
||||
|
||||
1. Créer un compte avec email et mot de passe
|
||||
2. Me connecter à mon compte
|
||||
3. Rester connecté entre les sessions
|
||||
4. Accéder au dashboard personnalisé
|
||||
|
||||
## ✅ Critères de Succès
|
||||
|
||||
### Scénario 1 : Inscription
|
||||
|
||||
**Given** : Je suis sur la page d'inscription `http://localhost:3000/register`
|
||||
**When** : Je remplis le formulaire avec des données valides
|
||||
**Then** :
|
||||
- ✅ Mon compte est créé dans la base de données
|
||||
- ✅ Un email de confirmation est envoyé (optionnel)
|
||||
- ✅ Je suis automatiquement connecté et redirigé vers le dashboard
|
||||
- ✅ Mes préférences sont initialisées (langue, notifications)
|
||||
|
||||
**Validation** :
|
||||
- Email : Format valide, unique dans la base de données
|
||||
- Mot de passe : Minimum 8 caractères, 1 majuscule, 1 chiffre, 1 caractère spécial
|
||||
- Confirmation mot de passe : Doit correspondre
|
||||
|
||||
### Scénario 2 : Connexion
|
||||
|
||||
**Given** : Je suis un utilisateur enregistré
|
||||
**When** : Je vais sur la page de connexion `http://localhost:3000/login`
|
||||
**Then** :
|
||||
- ✅ Je peux me connecter avec email et mot de passe
|
||||
- ✅ Je suis redirigé vers le dashboard
|
||||
- ✅ Ma session persiste (je reste connecté après fermer le navigateur)
|
||||
- ✅ J'accède à mes prédictions personnalisées
|
||||
|
||||
**Validation** :
|
||||
- Identifiants corrects
|
||||
- Session créée avec token JWT
|
||||
- Cookies sécurisés (HttpOnly, Secure, SameSite)
|
||||
|
||||
### Scénario 3 : Déconnexion
|
||||
|
||||
**Given** : Je suis connecté au dashboard
|
||||
**When** : Je clique sur "Déconnexion"
|
||||
**Then** :
|
||||
- ✅ Ma session est terminée
|
||||
- ✅ Je suis redirigé vers la page de connexion
|
||||
- ✅ Toutes mes données sensibles sont effacées du navigateur
|
||||
|
||||
### Scénario 4 : Réinitialisation Mot de Passe
|
||||
|
||||
**Given** : J'ai oublié mon mot de passe
|
||||
**When** : Je clique sur "Mot de passe oublié ?"
|
||||
**Then** :
|
||||
- ✅ Je peux demander une réinitialisation par email
|
||||
- ✅ Un email avec lien de réinitialisation est envoyé
|
||||
- ✅ Je peux définir un nouveau mot de passe via le lien
|
||||
|
||||
---
|
||||
|
||||
## 📱 Composants Frontend Requis
|
||||
|
||||
### Page d'Inscription (`src/app/(auth)/register/page.tsx`)
|
||||
|
||||
```tsx
|
||||
// Interface du formulaire
|
||||
interface RegisterFormData {
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
name?: string;
|
||||
referralCode?: string;
|
||||
}
|
||||
|
||||
// Validation des inputs
|
||||
const registerValidation = {
|
||||
email: {
|
||||
required: "L'email est requis",
|
||||
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
message: "Format d'email invalide"
|
||||
},
|
||||
password: {
|
||||
required: "Le mot de passe est requis",
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: "Le mot de passe doit contenir au moins 8 caractères"
|
||||
},
|
||||
pattern: {
|
||||
value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&])/,
|
||||
message: "Le mot de passe doit contenir 1 majuscule, 1 minuscule et 1 chiffre"
|
||||
}
|
||||
},
|
||||
confirmPassword: {
|
||||
required: "La confirmation est requise",
|
||||
validate: (value: string) => value === watch('password'),
|
||||
message: "Les mots de passe ne correspondent pas"
|
||||
}
|
||||
};
|
||||
|
||||
export default function RegisterPage() {
|
||||
const handleSubmit = async (formData: RegisterFormData) => {
|
||||
// Call API backend
|
||||
const response = await fetch('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Success - Store session and redirect
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
// Error - Display error message
|
||||
alert(data.error.message || "Erreur lors de l'inscription");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Card className="w-full max-w-md p-8">
|
||||
<CardHeader>
|
||||
<CardTitle>Créer un compte Chartbastan</CardTitle>
|
||||
<CardDescription>
|
||||
Rejoignez la communauté des prédictions sportives basées sur l'énergie collective.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Email Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="votre.email@exemple.com"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Utilisé pour la connexion et la récupération de compte
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="•••••••••"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Minimum 8 caractères : 1 majuscule, 1 minuscule, 1 chiffre
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="••••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name Input (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Nom (optionnel)</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Votre prénom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Referral Code Input (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="referralCode">Code de parrainage (optionnel)</Label>
|
||||
<Input
|
||||
id="referralCode"
|
||||
type="text"
|
||||
placeholder="EX: ABC12345"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Invitez 3 amis pour obtenir 1 mois premium GRATUIT
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button type="submit" className="w-full">
|
||||
Créer mon compte
|
||||
</Button>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="text-center mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Déjà inscrit ?{" "}
|
||||
<Link href="/login" className="text-primary hover:underline font-medium">
|
||||
Connectez-vous
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Page de Connexion (`src/app/(auth)/login/page.tsx`)
|
||||
|
||||
```tsx
|
||||
// Interface du formulaire
|
||||
interface LoginFormData {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe: boolean;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const handleSubmit = async (formData: LoginFormData) => {
|
||||
// Call API backend
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Success - Store session and redirect
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
// Error - Display error message
|
||||
alert(data.error.message || "Email ou mot de passe incorrect");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Card className="w-full max-w-md p-8">
|
||||
<CardHeader>
|
||||
<CardTitle>Connexion</CardTitle>
|
||||
<CardDescription>
|
||||
Accédez à votre dashboard et consultez les prédictions de matchs.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Email Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="votre.email@exemple.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remember Me Checkbox */}
|
||||
<div className="flex items-center space-y-2">
|
||||
<input
|
||||
id="rememberMe"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="rememberMe" className="text-sm">
|
||||
Se souvenir de moi
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Forgot Password Link */}
|
||||
<div className="text-center">
|
||||
<Link href="/forgot-password" className="text-sm text-primary hover:underline">
|
||||
Mot de passe oublié ?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button type="submit" className="w-full">
|
||||
Se connecter
|
||||
</Button>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="text-center mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pas encore de compte ?{" "}
|
||||
<Link href="/register" className="text-primary hover:underline font-medium">
|
||||
Créer un compte
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Session Management Hook (`src/hooks/useAuth.ts`)
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
name?: string;
|
||||
isPremium: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
// Load session from localStorage on mount
|
||||
useEffect(() => {
|
||||
const loadSession = () => {
|
||||
const session = localStorage.getItem('chartbastan_session');
|
||||
if (session) {
|
||||
const user = JSON.parse(session);
|
||||
setState({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadSession();
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string, rememberMe: boolean = false) => {
|
||||
setState({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
const { user, token } = data;
|
||||
|
||||
// Store session
|
||||
if (rememberMe) {
|
||||
localStorage.setItem('chartbastan_session', JSON.stringify(user));
|
||||
localStorage.setItem('chartbastan_token', token);
|
||||
}
|
||||
|
||||
setState({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(data.error?.message || 'Échec de la connexion');
|
||||
}
|
||||
} catch (error) {
|
||||
setState({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur de connexion'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
setState({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
// Clear local storage
|
||||
localStorage.removeItem('chartbastan_session');
|
||||
localStorage.removeItem('chartbastan_token');
|
||||
|
||||
setState({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
window.location.href = '/login';
|
||||
} catch (error) {
|
||||
setState({
|
||||
error: error instanceof Error ? error.message : 'Erreur de déconnexion'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (formData: {
|
||||
email: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
referralCode?: string;
|
||||
}) => {
|
||||
setState({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
const { user, token } = data;
|
||||
|
||||
// Auto-login after registration
|
||||
localStorage.setItem('chartbastan_session', JSON.stringify(user));
|
||||
localStorage.setItem('chartbastan_token', token);
|
||||
|
||||
setState({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(data.error?.message || 'Échec de l\'inscription');
|
||||
}
|
||||
} catch (error) {
|
||||
setState({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur d\'inscription'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
login,
|
||||
logout,
|
||||
register,
|
||||
clearError: () => setState({ error: null })
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Backend Requis
|
||||
|
||||
### Endpoints à Créer dans `backend/app/api/v1/auth.py`
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from passlib.context import CryptContext
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
# Configuration hashing
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
# Schemas
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, regex=r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)')
|
||||
name: str | None = None
|
||||
referral_code: str | None = None
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
remember_me: bool = False
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: str | None
|
||||
is_premium: bool
|
||||
created_at: str
|
||||
token: str
|
||||
|
||||
# Endpoints
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Inscription d'un nouvel utilisateur.
|
||||
|
||||
Validation:
|
||||
- Email unique dans la base
|
||||
- Mot de passe hashé avec pbkdf2_sha256
|
||||
- Création automatique de la session
|
||||
"""
|
||||
# Vérifier si l'email existe déjà
|
||||
existing_user = db.query(User).filter(User.email == request.email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cet email est déjà associé à un compte"
|
||||
)
|
||||
|
||||
# Hasher le mot de passe
|
||||
password_hash = pwd_context.hash(request.password)
|
||||
|
||||
# Créer l'utilisateur
|
||||
new_user = User(
|
||||
email=request.email,
|
||||
password_hash=password_hash,
|
||||
name=request.name,
|
||||
is_premium=False, # Utilisateur gratuit par défaut
|
||||
referral_code=request.referral_code
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
# Générer le token JWT (simulation pour Phase 1)
|
||||
# TODO: Intégrer une vraie librairie JWT dans Phase 2
|
||||
token = f"token_{new_user.id}_{new_user.email}"
|
||||
|
||||
return UserResponse(
|
||||
id=new_user.id,
|
||||
email=new_user.email,
|
||||
name=new_user.name,
|
||||
is_premium=new_user.is_premium,
|
||||
created_at=new_user.created_at.isoformat() if new_user.created_at else "",
|
||||
token=token
|
||||
)
|
||||
|
||||
@router.post("/login", response_model=UserResponse)
|
||||
async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Connexion d'un utilisateur.
|
||||
|
||||
Validation:
|
||||
- Vérification du hash du mot de passe
|
||||
- Génération d'un nouveau token de session
|
||||
"""
|
||||
# Rechercher l'utilisateur
|
||||
user = db.query(User).filter(User.email == request.email).first()
|
||||
|
||||
if not user or not user.password_hash:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Email ou mot de passe incorrect"
|
||||
)
|
||||
|
||||
# Vérifier le mot de passe
|
||||
if not pwd_context.verify(request.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Email ou mot de passe incorrect"
|
||||
)
|
||||
|
||||
# Générer un nouveau token
|
||||
token = f"token_{user.id}_{user.email}"
|
||||
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
is_premium=user.is_premium,
|
||||
created_at=user.created_at.isoformat() if user.created_at else "",
|
||||
token=token
|
||||
)
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_200_OK)
|
||||
async def logout(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Déconnexion d'un utilisateur.
|
||||
|
||||
En Phase 1, c'est principalement côté client (suppression du localStorage).
|
||||
En Phase 2, le backend invalidera le token.
|
||||
"""
|
||||
# TODO: Invalider le token dans Redis/Blacklist (Phase 2)
|
||||
return {"message": "Déconnexion réussie"}
|
||||
|
||||
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
|
||||
async def forgot_password(request: dict, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Réinitialisation du mot de passe (optionnel).
|
||||
|
||||
Envoie un email avec un lien de réinitialisation.
|
||||
"""
|
||||
email = request.get("email")
|
||||
|
||||
# Vérifier que l'utilisateur existe
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Aucun compte trouvé avec cet email"
|
||||
)
|
||||
|
||||
# TODO: Envoyer un email avec token de réinitialisation (Phase 2)
|
||||
# Pour l'instant, on simule l'envoi
|
||||
reset_token = f"reset_{user.id}_{user.email[:8]}"
|
||||
|
||||
return {
|
||||
"message": "Si cet email existe, un lien de réinitialisation sera envoyé",
|
||||
"note": "Fonctionnalité simulée pour Phase 1"
|
||||
}
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Récupère l'utilisateur connecté.
|
||||
|
||||
Utilise le token pour identifier l'utilisateur.
|
||||
En Phase 2, utilise une dépendance JWT réelle.
|
||||
"""
|
||||
# TODO: Valider le token JWT (Phase 2)
|
||||
# Pour l'instant, on retourne un utilisateur factice ou utilise l'ID
|
||||
|
||||
# Simulation : retourner le premier utilisateur si aucun token
|
||||
user = db.query(User).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Utilisateur non connecté"
|
||||
)
|
||||
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
is_premium=user.is_premium,
|
||||
created_at=user.created_at.isoformat() if user.created_at else "",
|
||||
token="current_session_token"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Tests Acceptation
|
||||
|
||||
### Tests Unitaires Frontend
|
||||
|
||||
```typescript
|
||||
// tests/use-auth.test.ts
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
describe('useAuth Hook', () => {
|
||||
it('devrait se charger avec null par défaut', () => {
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('devrait connecter avec succès', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => useAuth());
|
||||
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user: { id: 1, email: 'test@test.com', is_premium: false },
|
||||
token: 'token_123'
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const success = await result.current.login('test@test.com', 'password123', false);
|
||||
expect(success).toBe(true);
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.user).toEqual({
|
||||
id: 1,
|
||||
email: 'test@test.com',
|
||||
is_premium: false
|
||||
});
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
expect(localStorage.getItem('chartbastan_session')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('devrait gérer les erreurs de connexion', async () => {
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
json: async () => ({
|
||||
error: { message: 'Email ou mot de passe incorrect' }
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const success = await result.current.login('test@test.com', 'wrongpass');
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Email ou mot de passe incorrect');
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Tests d'Intégration Backend
|
||||
|
||||
```python
|
||||
# tests/test_auth.py
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.database import Base, get_db
|
||||
from app.models.user import User
|
||||
from app.main import app
|
||||
|
||||
# Setup base de données de test
|
||||
TEST_DATABASE_URL = "sqlite:///./test_chartbastan.db"
|
||||
engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
db = TestingSessionLocal()
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_register_success():
|
||||
"""Test l'inscription réussie."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "newuser@test.com",
|
||||
"password": "Password123",
|
||||
"name": "Test User"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "newuser@test.com"
|
||||
assert data["name"] == "Test User"
|
||||
assert "password" not in data
|
||||
assert "token" in data
|
||||
|
||||
def test_register_duplicate_email():
|
||||
"""Test l'inscription avec email déjà existant."""
|
||||
# Créer un utilisateur
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "duplicate@test.com",
|
||||
"password": "Password123"
|
||||
}
|
||||
)
|
||||
|
||||
# Essayer de créer le même email
|
||||
response = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "duplicate@test.com",
|
||||
"password": "Password456"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "déjà" in response.json()["detail"].lower()
|
||||
|
||||
def test_login_success():
|
||||
"""Test la connexion réussie."""
|
||||
# Créer un utilisateur
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "loginuser@test.com",
|
||||
"password": "Password123"
|
||||
}
|
||||
)
|
||||
|
||||
# Se connecter
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "loginuser@test.com",
|
||||
"password": "Password123"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == "loginuser@test.com"
|
||||
assert "token" in data
|
||||
|
||||
def test_login_wrong_password():
|
||||
"""Test la connexion avec mauvais mot de passe."""
|
||||
# Créer un utilisateur
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "wrongpass@test.com",
|
||||
"password": "Password123"
|
||||
}
|
||||
)
|
||||
|
||||
# Se connecter avec mauvais mot de passe
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "wrongpass@test.com",
|
||||
"password": "WrongPassword"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "incorrect" in response.json()["detail"].lower()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Définitions de Succès
|
||||
|
||||
- [ ] Un utilisateur peut créer un compte avec email et mot de passe
|
||||
- [ ] Le mot de passe est hashé de manière sécurisée (pbkdf2_sha256)
|
||||
- [ ] Un utilisateur peut se connecter avec ses identifiants
|
||||
- [ ] La session persiste après fermeture du navigateur
|
||||
- [ ] Un utilisateur peut se déconnecter
|
||||
- [ ] Les erreurs de connexion sont clairement affichées
|
||||
- [ ] L'inscription valide l'unicité de l'email
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Dépendances
|
||||
|
||||
**Frontend** :
|
||||
- `@/components/ui/card`, `label`, `input`, `button`
|
||||
- `@/hooks/useAuth` (hook personnalisé)
|
||||
|
||||
**Backend** :
|
||||
- `@/models/user` (modèle utilisateur)
|
||||
- `passlib` (hashing pbkdf2_sha256)
|
||||
- SQLAlchemy ORM
|
||||
|
||||
**Tests** :
|
||||
- `@testing-library/react` (tests frontend)
|
||||
- `pytest` (tests backend)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 KPIs de Mesure
|
||||
|
||||
- **Taux de conversion inscription** : % d'utilisateurs qui s'inscrivent
|
||||
- **Taux de réussite connexion** : % de tentatives de connexion réussies
|
||||
- **Temps de chargement page** : < 2s pour page inscription/connexion
|
||||
- **Temps de réponse API** : < 500ms pour endpoints auth
|
||||
- **Taux d'erreurs 401** : < 5% (trop d'erreurs = problème)
|
||||
|
||||
---
|
||||
|
||||
**Note** : Cette User Story couvre les fonctionnalités critiques de l'Epic 4. Une fois implémentée, les utilisateurs pourront créer des comptes et accéder à toutes les autres fonctionnalités du système.
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user