You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
880 lines
24 KiB
Markdown
880 lines
24 KiB
Markdown
|
2 months ago
|
# 📅 Semaine 6 - Auth Service
|
||
|
|
|
||
|
|
**Durée** : 20 heures
|
||
|
|
**Objectif** : Implémenter le service d'authentification avec Supabase Auth et Clean Architecture
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🎯 Objectifs de la Semaine
|
||
|
|
|
||
|
|
1. ✅ Implémenter Auth Service (Clean Architecture)
|
||
|
|
2. ✅ Configurer Supabase Auth (Email + OAuth Google)
|
||
|
|
3. ✅ Créer les pages d'authentification (UI)
|
||
|
|
4. ✅ Middleware de protection des routes
|
||
|
|
5. ✅ Tests unitaires et d'intégration (80%+ coverage)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📋 User Stories
|
||
|
|
|
||
|
|
### US-M01 : Créer un compte avec validation email
|
||
|
|
**En tant qu'** utilisateur
|
||
|
|
**Je veux** créer un compte avec mon email
|
||
|
|
**Afin de** pouvoir réserver des outils
|
||
|
|
|
||
|
|
**Critères d'acceptation** :
|
||
|
|
- ✅ Formulaire d'inscription avec email, mot de passe, nom, prénom
|
||
|
|
- ✅ Validation format email et force du mot de passe
|
||
|
|
- ✅ Email de confirmation envoyé par Supabase
|
||
|
|
- ✅ Compte activé après clic sur lien de confirmation
|
||
|
|
- ✅ Profil utilisateur créé automatiquement dans `profiles`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### US-M02 : Se connecter (Email + OAuth Google)
|
||
|
|
**En tant qu'** utilisateur enregistré
|
||
|
|
**Je veux** me connecter avec email/mot de passe ou Google
|
||
|
|
**Afin d'** accéder à mon espace
|
||
|
|
|
||
|
|
**Critères d'acceptation** :
|
||
|
|
- ✅ Formulaire de connexion avec email et mot de passe
|
||
|
|
- ✅ Bouton "Se connecter avec Google" (OAuth)
|
||
|
|
- ✅ Session persistante (JWT dans cookie)
|
||
|
|
- ✅ Redirection vers page d'origine après connexion
|
||
|
|
- ✅ Messages d'erreur clairs (sans fuite d'information)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🏗️ Architecture Auth Service
|
||
|
|
|
||
|
|
### Structure Clean Architecture
|
||
|
|
|
||
|
|
```
|
||
|
|
src/services/auth/
|
||
|
|
├── domain/
|
||
|
|
│ ├── entities/
|
||
|
|
│ │ └── user.entity.ts
|
||
|
|
│ ├── value-objects/
|
||
|
|
│ │ ├── email.vo.ts
|
||
|
|
│ │ └── password.vo.ts
|
||
|
|
│ └── repositories/
|
||
|
|
│ └── auth.repository.interface.ts
|
||
|
|
│
|
||
|
|
├── application/
|
||
|
|
│ ├── use-cases/
|
||
|
|
│ │ ├── register-user.use-case.ts
|
||
|
|
│ │ ├── login-user.use-case.ts
|
||
|
|
│ │ ├── logout-user.use-case.ts
|
||
|
|
│ │ ├── verify-email.use-case.ts
|
||
|
|
│ │ └── get-current-user.use-case.ts
|
||
|
|
│ └── services/
|
||
|
|
│ └── auth.service.ts
|
||
|
|
│
|
||
|
|
└── infrastructure/
|
||
|
|
├── repositories/
|
||
|
|
│ └── supabase-auth.repository.ts
|
||
|
|
└── dto/
|
||
|
|
├── register-user.dto.ts
|
||
|
|
└── login-user.dto.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📝 Implémentation Détaillée
|
||
|
|
|
||
|
|
### Jour 1 : Domain Layer (4h)
|
||
|
|
|
||
|
|
#### Fichier : `src/services/auth/domain/entities/user.entity.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { BaseEntity } from '@/shared/domain/entities/base.entity'
|
||
|
|
|
||
|
|
export interface UserProps {
|
||
|
|
id: string
|
||
|
|
email: string
|
||
|
|
nom?: string
|
||
|
|
prenom?: string
|
||
|
|
telephone?: string
|
||
|
|
adresse?: string
|
||
|
|
ville?: string
|
||
|
|
codePostal?: string
|
||
|
|
isAdmin: boolean
|
||
|
|
emailVerified: boolean
|
||
|
|
createdAt: Date
|
||
|
|
updatedAt: Date
|
||
|
|
}
|
||
|
|
|
||
|
|
export class User extends BaseEntity {
|
||
|
|
public readonly email: string
|
||
|
|
public nom?: string
|
||
|
|
public prenom?: string
|
||
|
|
public telephone?: string
|
||
|
|
public adresse?: string
|
||
|
|
public ville?: string
|
||
|
|
public codePostal?: string
|
||
|
|
public readonly isAdmin: boolean
|
||
|
|
public readonly emailVerified: boolean
|
||
|
|
|
||
|
|
private constructor(props: UserProps) {
|
||
|
|
super(props.id, props.createdAt, props.updatedAt)
|
||
|
|
this.email = props.email
|
||
|
|
this.nom = props.nom
|
||
|
|
this.prenom = props.prenom
|
||
|
|
this.telephone = props.telephone
|
||
|
|
this.adresse = props.adresse
|
||
|
|
this.ville = props.ville
|
||
|
|
this.codePostal = props.codePostal
|
||
|
|
this.isAdmin = props.isAdmin
|
||
|
|
this.emailVerified = props.emailVerified
|
||
|
|
}
|
||
|
|
|
||
|
|
public static create(props: UserProps): User {
|
||
|
|
return new User(props)
|
||
|
|
}
|
||
|
|
|
||
|
|
public get fullName(): string {
|
||
|
|
return `${this.prenom || ''} ${this.nom || ''}`.trim()
|
||
|
|
}
|
||
|
|
|
||
|
|
public get displayName(): string {
|
||
|
|
return this.fullName || this.email
|
||
|
|
}
|
||
|
|
|
||
|
|
public canAccessAdmin(): boolean {
|
||
|
|
return this.isAdmin
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Fichier : `src/services/auth/domain/value-objects/email.vo.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { Result } from '@/shared/domain/value-objects/result'
|
||
|
|
|
||
|
|
export class Email {
|
||
|
|
private readonly value: string
|
||
|
|
|
||
|
|
private constructor(email: string) {
|
||
|
|
this.value = email
|
||
|
|
}
|
||
|
|
|
||
|
|
public static create(email: string): Result<Email> {
|
||
|
|
if (!email || email.trim().length === 0) {
|
||
|
|
return Result.fail('Email est requis')
|
||
|
|
}
|
||
|
|
|
||
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||
|
|
if (!emailRegex.test(email)) {
|
||
|
|
return Result.fail('Format email invalide')
|
||
|
|
}
|
||
|
|
|
||
|
|
return Result.ok(new Email(email.toLowerCase().trim()))
|
||
|
|
}
|
||
|
|
|
||
|
|
public getValue(): string {
|
||
|
|
return this.value
|
||
|
|
}
|
||
|
|
|
||
|
|
public equals(other: Email): boolean {
|
||
|
|
return this.value === other.value
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Fichier : `src/services/auth/domain/value-objects/password.vo.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { Result } from '@/shared/domain/value-objects/result'
|
||
|
|
|
||
|
|
export class Password {
|
||
|
|
private readonly value: string
|
||
|
|
private static readonly MIN_LENGTH = 8
|
||
|
|
|
||
|
|
private constructor(password: string) {
|
||
|
|
this.value = password
|
||
|
|
}
|
||
|
|
|
||
|
|
public static create(password: string, validateStrength = true): Result<Password> {
|
||
|
|
if (!password || password.length === 0) {
|
||
|
|
return Result.fail('Mot de passe requis')
|
||
|
|
}
|
||
|
|
|
||
|
|
if (validateStrength) {
|
||
|
|
if (password.length < this.MIN_LENGTH) {
|
||
|
|
return Result.fail(`Mot de passe doit contenir au moins ${this.MIN_LENGTH} caractères`)
|
||
|
|
}
|
||
|
|
|
||
|
|
const hasUpperCase = /[A-Z]/.test(password)
|
||
|
|
const hasLowerCase = /[a-z]/.test(password)
|
||
|
|
const hasNumber = /[0-9]/.test(password)
|
||
|
|
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password)
|
||
|
|
|
||
|
|
if (!hasUpperCase || !hasLowerCase || !hasNumber) {
|
||
|
|
return Result.fail(
|
||
|
|
'Mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre'
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return Result.ok(new Password(password))
|
||
|
|
}
|
||
|
|
|
||
|
|
public getValue(): string {
|
||
|
|
return this.value
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Fichier : `src/services/auth/domain/repositories/auth.repository.interface.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { User } from '../entities/user.entity'
|
||
|
|
import { Result } from '@/shared/domain/value-objects/result'
|
||
|
|
|
||
|
|
export interface RegisterParams {
|
||
|
|
email: string
|
||
|
|
password: string
|
||
|
|
nom?: string
|
||
|
|
prenom?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface LoginParams {
|
||
|
|
email: string
|
||
|
|
password: string
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IAuthRepository {
|
||
|
|
register(params: RegisterParams): Promise<Result<User>>
|
||
|
|
login(params: LoginParams): Promise<Result<{ user: User; session: Session }>>
|
||
|
|
logout(): Promise<Result<void>>
|
||
|
|
getCurrentUser(): Promise<Result<User | null>>
|
||
|
|
loginWithGoogle(): Promise<Result<{ url: string }>>
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface Session {
|
||
|
|
accessToken: string
|
||
|
|
refreshToken: string
|
||
|
|
expiresAt: number
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Critères d'acceptation Jour 1** :
|
||
|
|
- ✅ Entity User créée avec business logic
|
||
|
|
- ✅ Value Objects Email et Password avec validation
|
||
|
|
- ✅ Interface IAuthRepository définie
|
||
|
|
- ✅ Tests unitaires pour User, Email, Password
|
||
|
|
|
||
|
|
**✅ JOUR 1 COMPLÉTÉ**
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Jour 2 : Infrastructure Layer (4h)
|
||
|
|
|
||
|
|
#### Fichier : `src/services/auth/infrastructure/repositories/supabase-auth.repository.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
|
||
|
|
import {
|
||
|
|
IAuthRepository,
|
||
|
|
RegisterParams,
|
||
|
|
LoginParams,
|
||
|
|
Session,
|
||
|
|
} from '../../domain/repositories/auth.repository.interface'
|
||
|
|
import { User } from '../../domain/entities/user.entity'
|
||
|
|
import { Result } from '@/shared/domain/value-objects/result'
|
||
|
|
import { Email } from '../../domain/value-objects/email.vo'
|
||
|
|
import { Password } from '../../domain/value-objects/password.vo'
|
||
|
|
|
||
|
|
export class SupabaseAuthRepository implements IAuthRepository {
|
||
|
|
private supabase = createClientComponentClient()
|
||
|
|
|
||
|
|
async register(params: RegisterParams): Promise<Result<User>> {
|
||
|
|
// Valider email
|
||
|
|
const emailOrError = Email.create(params.email)
|
||
|
|
if (emailOrError.isFailure) {
|
||
|
|
return Result.fail(emailOrError.error!)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Valider password
|
||
|
|
const passwordOrError = Password.create(params.password, true)
|
||
|
|
if (passwordOrError.isFailure) {
|
||
|
|
return Result.fail(passwordOrError.error!)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Créer l'utilisateur dans Supabase Auth
|
||
|
|
const { data, error } = await this.supabase.auth.signUp({
|
||
|
|
email: params.email,
|
||
|
|
password: params.password,
|
||
|
|
options: {
|
||
|
|
data: {
|
||
|
|
nom: params.nom,
|
||
|
|
prenom: params.prenom,
|
||
|
|
},
|
||
|
|
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
if (error) {
|
||
|
|
return Result.fail(this.mapSupabaseError(error.message))
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!data.user) {
|
||
|
|
return Result.fail('Erreur lors de la création du compte')
|
||
|
|
}
|
||
|
|
|
||
|
|
// Créer le profil dans la table profiles (via trigger ou manuellement)
|
||
|
|
const { error: profileError } = await this.supabase
|
||
|
|
.from('profiles')
|
||
|
|
.insert({
|
||
|
|
id: data.user.id,
|
||
|
|
email: params.email,
|
||
|
|
nom: params.nom,
|
||
|
|
prenom: params.prenom,
|
||
|
|
is_admin: false,
|
||
|
|
})
|
||
|
|
|
||
|
|
if (profileError) {
|
||
|
|
// Rollback : supprimer l'utilisateur auth
|
||
|
|
await this.supabase.auth.admin.deleteUser(data.user.id)
|
||
|
|
return Result.fail('Erreur lors de la création du profil')
|
||
|
|
}
|
||
|
|
|
||
|
|
const user = User.create({
|
||
|
|
id: data.user.id,
|
||
|
|
email: data.user.email!,
|
||
|
|
nom: params.nom,
|
||
|
|
prenom: params.prenom,
|
||
|
|
isAdmin: false,
|
||
|
|
emailVerified: false,
|
||
|
|
createdAt: new Date(data.user.created_at),
|
||
|
|
updatedAt: new Date(),
|
||
|
|
})
|
||
|
|
|
||
|
|
return Result.ok(user)
|
||
|
|
}
|
||
|
|
|
||
|
|
async login(params: LoginParams): Promise<Result<{ user: User; session: Session }>> {
|
||
|
|
const { data, error } = await this.supabase.auth.signInWithPassword({
|
||
|
|
email: params.email,
|
||
|
|
password: params.password,
|
||
|
|
})
|
||
|
|
|
||
|
|
if (error) {
|
||
|
|
return Result.fail('Email ou mot de passe incorrect')
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!data.user || !data.session) {
|
||
|
|
return Result.fail('Erreur de connexion')
|
||
|
|
}
|
||
|
|
|
||
|
|
// Récupérer le profil complet
|
||
|
|
const { data: profile, error: profileError } = await this.supabase
|
||
|
|
.from('profiles')
|
||
|
|
.select('*')
|
||
|
|
.eq('id', data.user.id)
|
||
|
|
.single()
|
||
|
|
|
||
|
|
if (profileError || !profile) {
|
||
|
|
return Result.fail('Profil utilisateur introuvable')
|
||
|
|
}
|
||
|
|
|
||
|
|
const user = User.create({
|
||
|
|
id: profile.id,
|
||
|
|
email: profile.email,
|
||
|
|
nom: profile.nom,
|
||
|
|
prenom: profile.prenom,
|
||
|
|
telephone: profile.telephone,
|
||
|
|
adresse: profile.adresse,
|
||
|
|
ville: profile.ville,
|
||
|
|
codePostal: profile.code_postal,
|
||
|
|
isAdmin: profile.is_admin,
|
||
|
|
emailVerified: !!data.user.email_confirmed_at,
|
||
|
|
createdAt: new Date(profile.created_at),
|
||
|
|
updatedAt: new Date(profile.updated_at),
|
||
|
|
})
|
||
|
|
|
||
|
|
const session: Session = {
|
||
|
|
accessToken: data.session.access_token,
|
||
|
|
refreshToken: data.session.refresh_token,
|
||
|
|
expiresAt: data.session.expires_at || 0,
|
||
|
|
}
|
||
|
|
|
||
|
|
return Result.ok({ user, session })
|
||
|
|
}
|
||
|
|
|
||
|
|
async logout(): Promise<Result<void>> {
|
||
|
|
const { error } = await this.supabase.auth.signOut()
|
||
|
|
|
||
|
|
if (error) {
|
||
|
|
return Result.fail('Erreur lors de la déconnexion')
|
||
|
|
}
|
||
|
|
|
||
|
|
return Result.ok()
|
||
|
|
}
|
||
|
|
|
||
|
|
async getCurrentUser(): Promise<Result<User | null>> {
|
||
|
|
const {
|
||
|
|
data: { session },
|
||
|
|
} = await this.supabase.auth.getSession()
|
||
|
|
|
||
|
|
if (!session) {
|
||
|
|
return Result.ok(null)
|
||
|
|
}
|
||
|
|
|
||
|
|
const { data: profile, error } = await this.supabase
|
||
|
|
.from('profiles')
|
||
|
|
.select('*')
|
||
|
|
.eq('id', session.user.id)
|
||
|
|
.single()
|
||
|
|
|
||
|
|
if (error || !profile) {
|
||
|
|
return Result.ok(null)
|
||
|
|
}
|
||
|
|
|
||
|
|
const user = User.create({
|
||
|
|
id: profile.id,
|
||
|
|
email: profile.email,
|
||
|
|
nom: profile.nom,
|
||
|
|
prenom: profile.prenom,
|
||
|
|
telephone: profile.telephone,
|
||
|
|
adresse: profile.adresse,
|
||
|
|
ville: profile.ville,
|
||
|
|
codePostal: profile.code_postal,
|
||
|
|
isAdmin: profile.is_admin,
|
||
|
|
emailVerified: !!session.user.email_confirmed_at,
|
||
|
|
createdAt: new Date(profile.created_at),
|
||
|
|
updatedAt: new Date(profile.updated_at),
|
||
|
|
})
|
||
|
|
|
||
|
|
return Result.ok(user)
|
||
|
|
}
|
||
|
|
|
||
|
|
async loginWithGoogle(): Promise<Result<{ url: string }>> {
|
||
|
|
const { data, error } = await this.supabase.auth.signInWithOAuth({
|
||
|
|
provider: 'google',
|
||
|
|
options: {
|
||
|
|
redirectTo: `${window.location.origin}/auth/callback`,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
if (error) {
|
||
|
|
return Result.fail('Erreur lors de la connexion Google')
|
||
|
|
}
|
||
|
|
|
||
|
|
return Result.ok({ url: data.url })
|
||
|
|
}
|
||
|
|
|
||
|
|
private mapSupabaseError(message: string): string {
|
||
|
|
if (message.includes('already registered')) {
|
||
|
|
return 'Cette adresse email est déjà utilisée'
|
||
|
|
}
|
||
|
|
if (message.includes('invalid email')) {
|
||
|
|
return 'Adresse email invalide'
|
||
|
|
}
|
||
|
|
return 'Une erreur est survenue'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Critères d'acceptation Jour 2** :
|
||
|
|
- ✅ SupabaseAuthRepository implémente IAuthRepository
|
||
|
|
- ✅ Gestion des erreurs Supabase
|
||
|
|
- ✅ Mapping vers domain entities
|
||
|
|
- ✅ Tests d'intégration avec Supabase
|
||
|
|
|
||
|
|
**✅ JOUR 2 COMPLÉTÉ**
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Jour 3 : Application Layer (4h)
|
||
|
|
|
||
|
|
#### Fichier : `src/services/auth/application/use-cases/register-user.use-case.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { IAuthRepository } from '../../domain/repositories/auth.repository.interface'
|
||
|
|
import { Result } from '@/shared/domain/value-objects/result'
|
||
|
|
import { User } from '../../domain/entities/user.entity'
|
||
|
|
|
||
|
|
export interface RegisterUserDTO {
|
||
|
|
email: string
|
||
|
|
password: string
|
||
|
|
nom?: string
|
||
|
|
prenom?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
export class RegisterUserUseCase {
|
||
|
|
constructor(private authRepository: IAuthRepository) {}
|
||
|
|
|
||
|
|
async execute(dto: RegisterUserDTO): Promise<Result<User>> {
|
||
|
|
// Validation métier supplémentaire (si nécessaire)
|
||
|
|
if (dto.nom && dto.nom.length < 2) {
|
||
|
|
return Result.fail('Le nom doit contenir au moins 2 caractères')
|
||
|
|
}
|
||
|
|
|
||
|
|
// Déléguer au repository
|
||
|
|
return await this.authRepository.register(dto)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Fichier : `src/services/auth/application/use-cases/login-user.use-case.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { IAuthRepository, Session } from '../../domain/repositories/auth.repository.interface'
|
||
|
|
import { Result } from '@/shared/domain/value-objects/result'
|
||
|
|
import { User } from '../../domain/entities/user.entity'
|
||
|
|
|
||
|
|
export interface LoginUserDTO {
|
||
|
|
email: string
|
||
|
|
password: string
|
||
|
|
}
|
||
|
|
|
||
|
|
export class LoginUserUseCase {
|
||
|
|
constructor(private authRepository: IAuthRepository) {}
|
||
|
|
|
||
|
|
async execute(dto: LoginUserDTO): Promise<Result<{ user: User; session: Session }>> {
|
||
|
|
return await this.authRepository.login(dto)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Fichier : `src/services/auth/application/services/auth.service.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { IAuthRepository } from '../../domain/repositories/auth.repository.interface'
|
||
|
|
import { RegisterUserUseCase } from '../use-cases/register-user.use-case'
|
||
|
|
import { LoginUserUseCase } from '../use-cases/login-user.use-case'
|
||
|
|
import { Result } from '@/shared/domain/value-objects/result'
|
||
|
|
import { User } from '../../domain/entities/user.entity'
|
||
|
|
|
||
|
|
export class AuthService {
|
||
|
|
private registerUserUseCase: RegisterUserUseCase
|
||
|
|
private loginUserUseCase: LoginUserUseCase
|
||
|
|
|
||
|
|
constructor(private authRepository: IAuthRepository) {
|
||
|
|
this.registerUserUseCase = new RegisterUserUseCase(authRepository)
|
||
|
|
this.loginUserUseCase = new LoginUserUseCase(authRepository)
|
||
|
|
}
|
||
|
|
|
||
|
|
async register(email: string, password: string, nom?: string, prenom?: string): Promise<Result<User>> {
|
||
|
|
return await this.registerUserUseCase.execute({ email, password, nom, prenom })
|
||
|
|
}
|
||
|
|
|
||
|
|
async login(email: string, password: string) {
|
||
|
|
return await this.loginUserUseCase.execute({ email, password })
|
||
|
|
}
|
||
|
|
|
||
|
|
async logout() {
|
||
|
|
return await this.authRepository.logout()
|
||
|
|
}
|
||
|
|
|
||
|
|
async getCurrentUser() {
|
||
|
|
return await this.authRepository.getCurrentUser()
|
||
|
|
}
|
||
|
|
|
||
|
|
async loginWithGoogle() {
|
||
|
|
return await this.authRepository.loginWithGoogle()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Critères d'acceptation Jour 3** :
|
||
|
|
- ✅ Use Cases créés (Register, Login)
|
||
|
|
- ✅ AuthService orchestre les Use Cases
|
||
|
|
- ✅ Tests unitaires pour Use Cases et Service
|
||
|
|
- ✅ Isolation des dépendances (mocking)
|
||
|
|
|
||
|
|
**✅ JOUR 3 COMPLÉTÉ**
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Jour 4 : UI & Pages (4h)
|
||
|
|
|
||
|
|
#### Page : `src/app/(auth)/register/page.tsx`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
'use client'
|
||
|
|
|
||
|
|
import { useState } from 'react'
|
||
|
|
import { useRouter } from 'next/navigation'
|
||
|
|
import { useForm } from 'react-hook-form'
|
||
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||
|
|
import * as z from 'zod'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Input } from '@/components/ui/input'
|
||
|
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||
|
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||
|
|
import { toast } from '@/components/ui/use-toast'
|
||
|
|
import { authService } from '@/lib/services'
|
||
|
|
import Link from 'next/link'
|
||
|
|
|
||
|
|
const registerSchema = z.object({
|
||
|
|
email: z.string().email('Email invalide'),
|
||
|
|
password: z
|
||
|
|
.string()
|
||
|
|
.min(8, 'Minimum 8 caractères')
|
||
|
|
.regex(/[A-Z]/, 'Doit contenir une majuscule')
|
||
|
|
.regex(/[a-z]/, 'Doit contenir une minuscule')
|
||
|
|
.regex(/[0-9]/, 'Doit contenir un chiffre'),
|
||
|
|
nom: z.string().min(2, 'Minimum 2 caractères').optional(),
|
||
|
|
prenom: z.string().min(2, 'Minimum 2 caractères').optional(),
|
||
|
|
})
|
||
|
|
|
||
|
|
type RegisterFormValues = z.infer<typeof registerSchema>
|
||
|
|
|
||
|
|
export default function RegisterPage() {
|
||
|
|
const router = useRouter()
|
||
|
|
const [loading, setLoading] = useState(false)
|
||
|
|
|
||
|
|
const form = useForm<RegisterFormValues>({
|
||
|
|
resolver: zodResolver(registerSchema),
|
||
|
|
defaultValues: {
|
||
|
|
email: '',
|
||
|
|
password: '',
|
||
|
|
nom: '',
|
||
|
|
prenom: '',
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
const onSubmit = async (values: RegisterFormValues) => {
|
||
|
|
setLoading(true)
|
||
|
|
|
||
|
|
const result = await authService.register(
|
||
|
|
values.email,
|
||
|
|
values.password,
|
||
|
|
values.nom,
|
||
|
|
values.prenom
|
||
|
|
)
|
||
|
|
|
||
|
|
if (result.isFailure) {
|
||
|
|
toast({
|
||
|
|
title: 'Erreur',
|
||
|
|
description: result.error,
|
||
|
|
variant: 'destructive',
|
||
|
|
})
|
||
|
|
setLoading(false)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
toast({
|
||
|
|
title: 'Compte créé !',
|
||
|
|
description: 'Vérifiez votre email pour confirmer votre compte.',
|
||
|
|
})
|
||
|
|
|
||
|
|
router.push('/login')
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||
|
|
<Card className="w-full max-w-md">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>Créer un compte</CardTitle>
|
||
|
|
<CardDescription>Inscrivez-vous pour louer des outils</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<Form {...form}>
|
||
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||
|
|
<FormField
|
||
|
|
control={form.control}
|
||
|
|
name="email"
|
||
|
|
render={({ field }) => (
|
||
|
|
<FormItem>
|
||
|
|
<FormLabel>Email</FormLabel>
|
||
|
|
<FormControl>
|
||
|
|
<Input type="email" placeholder="vous@exemple.com" {...field} />
|
||
|
|
</FormControl>
|
||
|
|
<FormMessage />
|
||
|
|
</FormItem>
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<FormField
|
||
|
|
control={form.control}
|
||
|
|
name="password"
|
||
|
|
render={({ field }) => (
|
||
|
|
<FormItem>
|
||
|
|
<FormLabel>Mot de passe</FormLabel>
|
||
|
|
<FormControl>
|
||
|
|
<Input type="password" placeholder="••••••••" {...field} />
|
||
|
|
</FormControl>
|
||
|
|
<FormMessage />
|
||
|
|
</FormItem>
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
<FormField
|
||
|
|
control={form.control}
|
||
|
|
name="prenom"
|
||
|
|
render={({ field }) => (
|
||
|
|
<FormItem>
|
||
|
|
<FormLabel>Prénom</FormLabel>
|
||
|
|
<FormControl>
|
||
|
|
<Input placeholder="Jean" {...field} />
|
||
|
|
</FormControl>
|
||
|
|
<FormMessage />
|
||
|
|
</FormItem>
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<FormField
|
||
|
|
control={form.control}
|
||
|
|
name="nom"
|
||
|
|
render={({ field }) => (
|
||
|
|
<FormItem>
|
||
|
|
<FormLabel>Nom</FormLabel>
|
||
|
|
<FormControl>
|
||
|
|
<Input placeholder="Dupont" {...field} />
|
||
|
|
</FormControl>
|
||
|
|
<FormMessage />
|
||
|
|
</FormItem>
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Button type="submit" className="w-full" disabled={loading}>
|
||
|
|
{loading ? 'Inscription...' : "S'inscrire"}
|
||
|
|
</Button>
|
||
|
|
</form>
|
||
|
|
</Form>
|
||
|
|
</CardContent>
|
||
|
|
<CardFooter className="flex flex-col space-y-2">
|
||
|
|
<div className="text-sm text-gray-600">
|
||
|
|
Déjà un compte ?{' '}
|
||
|
|
<Link href="/login" className="text-blue-600 hover:underline">
|
||
|
|
Se connecter
|
||
|
|
</Link>
|
||
|
|
</div>
|
||
|
|
</CardFooter>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Page : `src/app/(auth)/login/page.tsx`
|
||
|
|
|
||
|
|
(Similaire à register, avec OAuth Google en plus)
|
||
|
|
|
||
|
|
**Critères d'acceptation Jour 4** :
|
||
|
|
- ✅ Page d'inscription responsive et accessible
|
||
|
|
- ✅ Page de connexion avec OAuth Google
|
||
|
|
- ✅ Validation côté client (Zod + React Hook Form)
|
||
|
|
- ✅ Messages d'erreur clairs
|
||
|
|
- ✅ UI professionnelle (shadcn/ui)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Jour 5 : Middleware & Tests (4h)
|
||
|
|
|
||
|
|
**✅ JOUR 5 COMPLÉTÉ**
|
||
|
|
|
||
|
|
#### Middleware : `src/middleware.ts`
|
||
|
|
|
||
|
|
Route protection implementation with next/server for edge execution:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
||
|
|
|
||
|
|
const PROTECTED_ROUTES = ['/reservations', '/mon-profil', '/dashboard']
|
||
|
|
const ADMIN_ROUTES = ['/admin']
|
||
|
|
const PUBLIC_AUTH_ROUTES = ['/auth/login', '/auth/register', '/auth/callback', '/auth/email-confirmation']
|
||
|
|
|
||
|
|
export async function middleware(request: NextRequest) {
|
||
|
|
const pathname = request.nextUrl.pathname
|
||
|
|
const token = request.cookies.get('sb-auth-token')?.value
|
||
|
|
|
||
|
|
// Redirect authenticated users away from auth pages
|
||
|
|
if (PUBLIC_AUTH_ROUTES.some(route => pathname.startsWith(route)) && token) {
|
||
|
|
return NextResponse.redirect(`${request.nextUrl.origin}/dashboard`)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Protect private routes
|
||
|
|
if (PROTECTED_ROUTES.some(route => pathname.startsWith(route)) && !token) {
|
||
|
|
return NextResponse.redirect(`${request.nextUrl.origin}/auth/login`)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Protect admin routes
|
||
|
|
if (ADMIN_ROUTES.some(route => pathname.startsWith(route)) && !token) {
|
||
|
|
return NextResponse.redirect(`${request.nextUrl.origin}/auth/login`)
|
||
|
|
}
|
||
|
|
|
||
|
|
return NextResponse.next()
|
||
|
|
}
|
||
|
|
|
||
|
|
export const config = {
|
||
|
|
matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'],
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Tests E2E** : Voir fichier `tests/e2e/auth.spec.ts` pour 20 tests Playwright complets
|
||
|
|
|
||
|
|
**Implementation Details**:
|
||
|
|
- ✅ Edge middleware for performance (runs at CDN level)
|
||
|
|
- ✅ Token-based authentication check (Supabase session)
|
||
|
|
- ✅ Protected routes enforcement (/reservations, /mon-profil, /admin)
|
||
|
|
- ✅ Admin role validation preparation
|
||
|
|
- ✅ Redirect to login for unauthorized users
|
||
|
|
- ✅ Redirect to dashboard for authenticated users accessing auth pages
|
||
|
|
|
||
|
|
**Test Coverage** :
|
||
|
|
- ✅ 20 E2E tests covering:
|
||
|
|
- Registration flow with validation
|
||
|
|
- Login flow with OAuth
|
||
|
|
- Protected route access
|
||
|
|
- Email confirmation
|
||
|
|
- OAuth callback handling
|
||
|
|
- Form validation (email, password strength)
|
||
|
|
- ✅ 14 unit tests passing (Domain + Application layers)
|
||
|
|
- ✅ Auth layer >80% test coverage
|
||
|
|
|
||
|
|
**Build Status**: ✅ Successful (Next.js 16.0.1 Turbopack compilation)
|
||
|
|
```
|
||
|
|
✓ Compiled successfully in 16.0s
|
||
|
|
✓ Generating static pages (9/9) in 2.2s
|
||
|
|
ƒ Proxy (Middleware) - Running at edge
|
||
|
|
```
|
||
|
|
|
||
|
|
**Critères d'acceptation Jour 5** :
|
||
|
|
- ✅ Middleware protège les routes privées (/admin/*, /reservations/*, /mon-profil/*)
|
||
|
|
- ✅ Vérification token et sessions
|
||
|
|
- ✅ Tests unitaires > 80% coverage (auth service layer)
|
||
|
|
- ✅ Tests d'intégration E2E (Playwright) - 20 tests complets
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ✅ Checklist Fin de Semaine 6
|
||
|
|
|
||
|
|
### Implémentation
|
||
|
|
- [x] Domain Layer (User, Email, Password, interfaces)
|
||
|
|
- [x] Infrastructure Layer (SupabaseAuthRepository)
|
||
|
|
- [x] Application Layer (Use Cases, AuthService)
|
||
|
|
- [x] UI Layer (Pages Register, Login)
|
||
|
|
- [x] Middleware de protection
|
||
|
|
|
||
|
|
### Fonctionnalités
|
||
|
|
- [x] US-M01 : Inscription avec validation email
|
||
|
|
- [x] US-M02 : Connexion Email + OAuth Google
|
||
|
|
- [x] Protection des routes privées
|
||
|
|
- [x] Gestion des sessions (JWT)
|
||
|
|
|
||
|
|
### Tests
|
||
|
|
- [x] Tests unitaires Domain Layer (Email, Password value objects + User entity)
|
||
|
|
- [x] Tests unitaires Application Layer (Use cases + AuthService)
|
||
|
|
- [x] Tests d'intégration Supabase (Repository layer)
|
||
|
|
- [x] Tests E2E parcours complet (20 tests Playwright)
|
||
|
|
- [x] Coverage >80% pour auth service layer
|
||
|
|
|
||
|
|
### Documentation
|
||
|
|
- [x] Diagrammes architecture (voir ci-dessus)
|
||
|
|
- [x] Documentation middleware
|
||
|
|
- [x] Critères d'acceptation complets
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🚀 Prochaine Étape
|
||
|
|
|
||
|
|
**Semaine 7** : Implémenter Catalogue Service
|
||
|
|
|
||
|
|
Voir : [Semaine 7 - Catalogue Service](./WEEK_7_CATALOGUE.md)
|