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.

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

  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

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

Voir : Semaine 7 - Catalogue Service