24 KiB
📅 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
- ✅ Implémenter Auth Service (Clean Architecture)
- ✅ Configurer Supabase Auth (Email + OAuth Google)
- ✅ Créer les pages d'authentification (UI)
- ✅ Middleware de protection des routes
- ✅ 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
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
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
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
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
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
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
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
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
'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:
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
- Domain Layer (User, Email, Password, interfaces)
- Infrastructure Layer (SupabaseAuthRepository)
- Application Layer (Use Cases, AuthService)
- UI Layer (Pages Register, Login)
- Middleware de protection
Fonctionnalités
- US-M01 : Inscription avec validation email
- US-M02 : Connexion Email + OAuth Google
- Protection des routes privées
- Gestion des sessions (JWT)
Tests
- Tests unitaires Domain Layer (Email, Password value objects + User entity)
- Tests unitaires Application Layer (Use cases + AuthService)
- Tests d'intégration Supabase (Repository layer)
- Tests E2E parcours complet (20 tests Playwright)
- Coverage >80% pour auth service layer
Documentation
- Diagrammes architecture (voir ci-dessus)
- Documentation middleware
- Critères d'acceptation complets
🚀 Prochaine Étape
Semaine 7 : Implémenter Catalogue Service