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 ​

0. Ajouter setAuthToken dans le plugin Axios ​

Le store d'authentification a besoin de configurer le token JWT dans les headers HTTP. Ajoutez cette fonction dans src/plugins/axios.js :

js
/**
 * Configure le token d'authentification dans les headers Axios.
 * Appelée par authStore lors du login/logout.
 * @param {string|null} token - Le token JWT ou null pour le supprimer
 */
export function setAuthToken (token) {
  if (token) {
    api.defaults.headers.common.Authorization = `Bearer ${token}`
  } else {
    delete api.defaults.headers.common.Authorization
  }
}

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