# 📅 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 { 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 { 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> login(params: LoginParams): Promise> logout(): Promise> getCurrentUser(): Promise> loginWithGoogle(): Promise> } 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> { // 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> { 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> { const { error } = await this.supabase.auth.signOut() if (error) { return Result.fail('Erreur lors de la déconnexion') } return Result.ok() } async getCurrentUser(): Promise> { 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> { 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> { // 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> { 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> { 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 export default function RegisterPage() { const router = useRouter() const [loading, setLoading] = useState(false) const form = useForm({ 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 (
Créer un compte Inscrivez-vous pour louer des outils
( Email )} /> ( Mot de passe )} />
( Prénom )} /> ( Nom )} />
Déjà un compte ?{' '} Se connecter
) } ``` #### 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)