Skip to content

Étape 19 — Authentification et protection de routes

Séquence démo — pas requis dans le projet personnel

L'authentification et les guards de navigation sont présentés en démonstration. Ces concepts seront abordés à l'oral mais ne font pas partie des livrables du projet individuel.

Objectifs

  • Créer un store d'authentification factice (authStore)
  • Construire une page de connexion avec validation
  • Protéger des routes avec un guard de navigation (router.beforeEach)
  • Adapter l'en-tête selon l'état de connexion (v-if)

Prérequis — Point de départ

Cette étape nécessite d'avoir terminé les étapes 1 à 18 (séquences 1-6). Si votre code n'est pas à jour, vous pouvez repartir de la branche etape-19-start :

bash
# Ajouter le dépôt du prof (une seule fois)
git remote add prof https://github.com/fallinov/esig-141-pokedex-vuetify.git
# Récupérer les branches et basculer
git fetch prof
git checkout -b etape-19-start prof/etape-19-start
npm install

Voir la branche sur GitHub

Résultat attendu

Page de connexion

Concepts clés

Guard de navigation

Un guard de navigation, c'est comme un videur devant une boîte de nuit : il vérifie que vous avez le droit d'entrer avant de vous laisser passer. Si vous n'êtes pas sur la liste (pas authentifié), il vous renvoie ailleurs (page de connexion).

js
// Avant chaque changement de page...
router.beforeEach((to) => {
  const authStore = useAuthStore()

  // Si la route est protégée ET l'utilisateur n'est pas connecté
  if (protectedRoutes.includes(to.path) && !authStore.isAuthenticated) {
    return { path: '/login' } // Redirection vers la connexion
  }
})

Authentification factice

En production, l'authentification passe par une vraie API (JWT, OAuth, etc.). Ici, on simule avec des credentials en dur pour se concentrer sur le mécanisme : store, token, guards.

Tâches

1. Créer le store src/stores/authStore.js

Le store gère trois choses :

  • login(email, password) : vérifie les credentials et stocke le token
  • logout() : supprime le token et l'utilisateur
  • isAuthenticated : getter qui retourne true si un token existe

Credentials de test : sacha@pokemon.com / pika

2. Créer la page src/pages/login.vue

La page de connexion contient :

  • Un champ email avec validation (format email)
  • Un champ mot de passe avec bouton pour afficher/masquer
  • Un message d'erreur si les credentials sont incorrects
  • Une indication des credentials de test
ComposantRôle
v-text-field type emailChamp email avec icône mdi-email
v-text-field type passwordChamp mot de passe avec toggle visibilité
v-alert type errorMessage d'erreur conditionnel
v-btn blockBouton de connexion pleine largeur

Toggle du mot de passe

Le type du champ bascule entre password et text grâce à une variable réactive showPassword :

vue
<v-text-field
  :type="showPassword ? 'text' : 'password'"
  :append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
  @click:append-inner="showPassword = !showPassword"
/>

3. Ajouter le guard dans src/router/index.js

Le guard intercepte chaque navigation et vérifie si la route de destination nécessite une authentification.

js
// Liste des routes protégées
const protectedRoutes = ['/ajouter']

// Guard global
router.beforeEach((to) => {
  const authStore = useAuthStore()

  if (protectedRoutes.includes(to.path) && !authStore.isAuthenticated) {
    return { path: '/login' }
  }
})

4. Modifier App.vue — Charger le token au démarrage

Dans src/App.vue, importez le authStore et appelez authStore.loadToken() dans onMounted. Cela permet de restaurer la session utilisateur après un rechargement de page :

js
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/authStore'
import { usePokemonStore } from '@/stores/pokemonStore'

onMounted(async () => {
  const authStore = useAuthStore()
  authStore.loadToken() 

  const pokemonStore = usePokemonStore()
  await pokemonStore.init()
})

Sans cet appel, l'utilisateur serait déconnecté à chaque rechargement de page, même si le token est encore dans le localStorage.

5. Adapter l'en-tête (AppHeader)

L'en-tête affiche des éléments différents selon l'état de connexion :

  • Non connecté : bouton icône « Connexion » (mdi-login) qui mène à /login
  • Connecté : bouton « Ajouter » (mdi-plus-circle) + bouton « Déconnexion » (mdi-logout)
  • Un snackbar confirme la déconnexion

Pour le desktop (dans la v-app-bar) :

vue
<!-- Lien Ajouter (visible uniquement si connecté) -->
<v-btn
  v-if="authStore.isAuthenticated"
  icon="mdi-plus-circle"
  to="/ajouter"
  class="d-none d-md-flex"
/>

<!-- Bouton Déconnexion (si connecté) -->
<v-btn
  v-if="authStore.isAuthenticated"
  icon="mdi-logout"
  class="d-none d-md-flex"
  @click="handleLogout"
/>

<!-- Bouton Connexion (si non connecté) -->
<v-btn
  v-else
  icon="mdi-login"
  to="/login"
  class="d-none d-md-flex"
/>

Pour le mobile (dans le v-navigation-drawer), ajoutez les mêmes éléments sous forme de v-list-item.

La fonction handleLogout déconnecte l'utilisateur, affiche un snackbar de confirmation et redirige vers l'accueil :

js
function handleLogout () {
  authStore.logout()
  snackbar.value = true
  router.push('/')
}

Tests

  • La page /login affiche un formulaire email + mot de passe
  • sacha@pokemon.com / pika → connexion réussie, redirection vers /
  • Mauvais credentials → message d'erreur affiché
  • Accès à /ajouter sans être connecté → redirection vers /login
  • Après connexion, l'en-tête affiche le nom et le bouton « Déconnexion »
  • Après déconnexion, le bouton « Connexion » réapparaît

Solution

src/stores/authStore.js
js
import { defineStore } from 'pinia'
import { setAuthToken } from '@/plugins/axios'

/**
 * Données factices pour simuler l'authentification
 * En production, ces données viendraient d'une API
 */
const utilisateurFactice = {
  email: 'sacha@pokemon.com',
  name: 'Sacha Ketchum',
}

const passwordFactice = 'pika'
const tokenFactice = '0b042934e5df02c9786efb364d946e64'

export const useAuthStore = defineStore('auth', {
  /**
   * État initial
   * - user : informations de l'utilisateur connecté (null si déconnecté)
   * - token : jeton d'authentification (null si déconnecté)
   */
  state: () => ({
    user: null,
    token: null,
  }),

  actions: {
    /**
     * Simule la connexion d'un utilisateur
     * Compare les credentials avec les données factices
     */
    login (email, password) {
      if (email === utilisateurFactice.email && password === passwordFactice) {
        // Credentials corrects → stocker l'utilisateur et le token
        this.user = utilisateurFactice
        this.token = tokenFactice
        setAuthToken(this.token)
        localStorage.setItem('token', this.token)
        return { success: true, message: 'Connexion réussie' }
      } else {
        // Credentials incorrects → tout réinitialiser
        this.user = null
        this.token = null
        setAuthToken(null)
        localStorage.removeItem('token')
        return { success: false, message: 'Mauvais email ou mot de passe !' }
      }
    },

    /**
     * Déconnecte l'utilisateur
     * Supprime le token du store et du localStorage
     */
    logout () {
      this.user = null
      this.token = null
      setAuthToken(null)
      localStorage.removeItem('token')
      return { success: true, message: 'Déconnexion réussie' }
    },

    /**
     * Recharge le token depuis le localStorage
     * Permet de maintenir la session après un rechargement de page
     */
    loadToken () {
      const token = localStorage.getItem('token')
      if (token) {
        this.user = utilisateurFactice
        this.token = token
        setAuthToken(token)
      }
    },
  },

  getters: {
    /**
     * Retourne true si un token est présent (utilisateur connecté)
     */
    isAuthenticated: state => !!state.token,
  },
})
src/pages/login.vue
vue
<template>
  <v-container>
    <h1 class="text-h3 text-center my-6">
      Connexion
    </h1>

    <!--
    Formulaire de connexion
      * Centré avec max-width et mx-auto
      * @submit.prevent empêche le rechargement de la page
    -->
    <v-card
      max-width="400"
      class="mx-auto pa-6"
    >
      <v-form
        ref="formRef"
        @submit.prevent="submitLogin"
      >
        <!--
        Champ email
          * type="email" active la validation HTML5 du navigateur
          * :rules applique les règles de validation Vue
        -->
        <v-text-field
          v-model="email"
          label="Email"
          type="email"
          :rules="emailRules"
          prepend-inner-icon="mdi-email"
          variant="outlined"
          class="mb-2"
        />

        <!--
        Champ mot de passe
          * :type bascule entre "password" (caché) et "text" (visible)
          * Le bouton oeil permet de montrer/cacher le mot de passe
        -->
        <v-text-field
          v-model="password"
          label="Mot de passe"
          :type="showPassword ? 'text' : 'password'"
          :rules="passwordRules"
          prepend-inner-icon="mdi-lock"
          :append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
          variant="outlined"
          class="mb-2"
          @click:append-inner="showPassword = !showPassword"
        />

        <!--
        Message d'erreur si la connexion échoue
        -->
        <v-alert
          v-if="errorMessage"
          type="error"
          variant="tonal"
          class="mb-4"
        >
          {{ errorMessage }}
        </v-alert>

        <!--
        Bouton de connexion
          * type="submit" soumet le formulaire
          * block prend toute la largeur
        -->
        <v-btn
          type="submit"
          color="primary"
          block
          size="large"
        >
          Se connecter
        </v-btn>
      </v-form>

      <!--
      Aide pour l'utilisateur (credentials de test)
      -->
      <v-card-text class="text-center text-caption mt-4">
        <strong>Compte de test :</strong><br>
        sacha@pokemon.com / pika
      </v-card-text>
    </v-card>
  </v-container>
</template>

<script setup>
import { useAuthStore } from '@/stores/authStore'

const authStore = useAuthStore()
const router = useRouter()

const formRef = ref(null)
const email = ref('')
const password = ref('')
const showPassword = ref(false)
const errorMessage = ref('')

/**
 * Règles de validation pour l'email
 */
const emailRules = [
  v => !!v || 'L\'email est obligatoire',
  v => /.+@.+\..+/.test(v) || 'L\'email doit être valide',
]

/**
 * Règles de validation pour le mot de passe
 */
const passwordRules = [
  v => !!v || 'Le mot de passe est obligatoire',
]

/**
 * Soumission du formulaire de connexion
 * 1. Valide le formulaire
 * 2. Tente la connexion via authStore.login()
 * 3. Redirige vers l'accueil si succès, affiche l'erreur sinon
 */
async function submitLogin () {
  const { valid } = await formRef.value.validate()
  if (!valid) return

  errorMessage.value = ''

  const result = authStore.login(email.value, password.value)

  if (result.success) {
    router.push('/')
  } else {
    errorMessage.value = result.message
  }
}
</script>
src/App.vue (ajout de loadToken)
vue
<template>
  <v-app>
    <app-header />

    <v-main>
      <router-view />
    </v-main>

    <app-footer />
  </v-app>
</template>

<script setup>
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/authStore'
import { usePokemonStore } from '@/stores/pokemonStore'

onMounted(async () => {
  const authStore = useAuthStore()
  authStore.loadToken()

  const pokemonStore = usePokemonStore()
  await pokemonStore.init()
})
</script>
src/components/AppHeader.vue
vue
<template>
  <v-app-bar flat>
    <v-container class="d-flex align-center">
      <v-app-bar-nav-icon
        class="d-md-none"
        @click="drawer = !drawer"
      />

      <v-avatar
        class="mr-4 pa-0 cursor-pointer"
        image="@/assets/pokeball.svg"
        size="64"
        @click="$router.push('/')"
      />

      <v-toolbar-title>Pokédex</v-toolbar-title>

      <v-btn
        v-for="link in menuItems"
        :key="link.title"
        :icon="link.icon"
        :to="link.path"
        class="d-none d-md-flex"
      />

      <!-- Lien Ajouter (visible uniquement si connecté) -->
      <v-btn
        v-if="authStore.isAuthenticated"
        icon="mdi-plus-circle"
        to="/ajouter"
        class="d-none d-md-flex"
      />

      <!-- Bouton Déconnexion (si connecté) -->
      <v-btn
        v-if="authStore.isAuthenticated"
        icon="mdi-logout"
        class="d-none d-md-flex"
        @click="handleLogout"
      />
      <!-- Bouton Connexion (si non connecté) -->
      <v-btn
        v-else
        icon="mdi-login"
        to="/login"
        class="d-none d-md-flex"
      />
    </v-container>
  </v-app-bar>

  <!-- Tiroir de navigation (mobile) -->
  <v-navigation-drawer
    v-model="drawer"
    temporary
  >
    <v-list nav>
      <v-list-item
        v-for="link in menuItems"
        :key="link.title"
        :prepend-icon="link.icon"
        :title="link.title"
        :to="link.path"
        @click="drawer = false"
      />

      <!-- Lien Ajouter (mobile, si connecté) -->
      <v-list-item
        v-if="authStore.isAuthenticated"
        prepend-icon="mdi-plus-circle"
        title="Ajouter"
        to="/ajouter"
        @click="drawer = false"
      />

      <v-divider class="my-2" />

      <!-- Déconnexion (mobile) -->
      <v-list-item
        v-if="authStore.isAuthenticated"
        prepend-icon="mdi-logout"
        title="Déconnexion"
        @click="handleLogout(); drawer = false"
      />
      <!-- Connexion (mobile) -->
      <v-list-item
        v-else
        prepend-icon="mdi-login"
        title="Connexion"
        to="/login"
        @click="drawer = false"
      />
    </v-list>
  </v-navigation-drawer>

  <!-- Snackbar de confirmation de déconnexion -->
  <v-snackbar
    v-model="snackbar"
    :timeout="2000"
    color="info"
  >
    Déconnexion réussie
  </v-snackbar>
</template>

<script setup>
import { useAuthStore } from '@/stores/authStore'

const authStore = useAuthStore()
const router = useRouter()

const menuItems = [
  { title: 'Accueil', path: '/', icon: 'mdi-pokeball' },
  { title: 'Favoris', path: '/favoris', icon: 'mdi-heart' },
  { title: 'À propos', path: '/a-propos', icon: 'mdi-information' },
]

const drawer = ref(false)
const snackbar = ref(false)

function handleLogout () {
  authStore.logout()
  snackbar.value = true
  router.push('/')
}
</script>
src/router/index.js (ajout du guard)
js
import { createRouter, createWebHistory } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'
import { useAuthStore } from '@/stores/authStore'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
})

/**
 * Routes qui nécessitent d'être authentifié
 * Si l'utilisateur n'est pas connecté, il est redirigé vers /login
 */
const protectedRoutes = ['/ajouter']

/**
 * Guard de navigation globale
 * Vérifie l'authentification avant chaque changement de route
 */
router.beforeEach((to) => {
  const authStore = useAuthStore()

  // Vérifier si la route nécessite une authentification
  if (protectedRoutes.includes(to.path) && !authStore.isAuthenticated) {
    // Rediriger vers la page de connexion
    return { path: '/login' }
  }
})

export default router

Commit

bash
git add -A
git commit -m "feat: authentification factice et guards de navigation"

Documentation pour les cours de développement web