feat(auth): scaffold auth pages and guards

- Add Pinia auth store, types, and auth service (mocked)
- Add login/register/verify/forgot password views and dashboard
- Add reusable auth UI components (card, inputs, buttons)
- Add router guards and protected routing wiring
- Disable vue devtools plugin by default to avoid Node localStorage crash
- Update dependencies for cookie handling
- Comment out Umami script in index.html

Made-with: Cursor
This commit is contained in:
2026-04-07 22:43:41 +02:00
parent 2378febe6f
commit 7970bcc0fd
19 changed files with 2495 additions and 14 deletions
+180
View File
@@ -0,0 +1,180 @@
Parfait Killian. Voici **tous les prompts structurés bloc par bloc** pour développer ta **plateforme de formation ultra sécurisée avec abonnement Stripe, cours vidéo/textes, exercices exécutables, progression, etc.** dans Cursor.
Chaque bloc est indépendant et peut être copié-collé dans Cursor directement pour guider la création pas à pas.
---
**🧱 Bloc 1 Authentification sécurisée (frontend uniquement)**
```
Tu crées une plateforme de formation avec cours vidéo/textes, exercices interactifs, paiement par abonnement via Stripe, progression personnalisée et haut niveau de sécurité.
Commence par créer lauthentification côté frontend (Vue 3 + TypeScript).
Fonctionnalités :
- Inscription (email, mot de passe, plan choisi — non fonctionnel pour linstant)
- Connexion avec token (JWT ou cookie, à simuler)
- Déconnexion
- Mot de passe oublié
- Vérification demail (page après inscription)
Implémente :
- un store Pinia `useAuthStore`
- une couche `authService.ts` avec appels mockés
- des vues `LoginView`, `RegisterView`, `ForgotPasswordView`
- composants UI réutilisables pour formulaire
- navigation protégée (route guards)
```
---
**🧱 Bloc 2 Intégration Stripe (paiement + plans)**
```
Ajoute le système de paiement mensuel avec Stripe.
Fonctionnalités :
- Plans dabonnement multiples (mensuel, annuel, premium, etc.)
- Paiement sécurisé via Stripe Checkout ou Billing Portal
- Stockage du statut de souscription côté backend
- Accès conditionnel aux cours selon le plan
Frontend :
- Crée une page "Plans" avec boutons "Sabonner"
- Intègre Stripe.js et redirige vers Checkout
- Gère le retour de paiement via `success_url` / `cancel_url`
Backend (simulé ou prêt pour implémentation future) :
- Création dun client Stripe à linscription
- Webhooks Stripe pour gérer statut de paiement
```
---
**🧱 Bloc 3 Architecture des cours (textes + vidéos + code)**
```
Crée la structure dun cours dans le système.
Fonctionnalités :
- Un cours contient plusieurs chapitres
- Un chapitre contient des leçons (texte, vidéo, exercices)
- Une leçon contient du contenu markdown + vidéo + zone de code
Modèle (à représenter en TypeScript dans frontend pour linstant) :
- Course: id, title, description, cover, plan_required
- Chapter: id, course_id, title, position
- Lesson: id, chapter_id, title, content (markdown), video_url, exercise_type
Crée les vues :
- `CourseOverview.vue` pour explorer un cours
- `LessonViewer.vue` avec affichage markdown + vidéo + éditeur de code
```
---
**🧱 Bloc 4 Progression utilisateur dans les cours**
```
Crée un système pour suivre la progression individuelle de lutilisateur.
Fonctionnalités :
- Chaque utilisateur peut suivre plusieurs cours
- Chaque leçon peut être marquée comme complétée
- Affichage de la progression dans le cours (ex: 4/10 leçons terminées)
- Synchronisé avec le backend (mocké pour linstant)
Implémente :
- Un store Pinia `useProgressStore`
- Une structure : { user_id, course_id, completed_lessons: [lesson_ids] }
- Affiche des badges de progression dans lUI
```
---
**🧱 Bloc 5 Éditeur de code avec exécution sandboxée**
```
Ajoute une zone dexécution de code dans certaines leçons (exercices interactifs).
Fonctionnalités :
- Zone d’édition avec CodeMirror ou Monaco Editor
- Bouton "Exécuter"
- Envoi du code à une API dexécution (Docker, VM, ou service type Piston ou Playground)
- Récupération de loutput stdout/stderr
Commence avec un backend mocké (`codeRunnerService.ts`) qui renvoie une sortie simulée, prêt à être branché sur un vrai moteur plus tard.
UI :
- Composant `CodeExercise.vue` avec éditeur + output + reset
- Validation des exercices dans la progression si output match attendu
```
---
**🧱 Bloc 6 Sécurité avancée & protection des routes**
```
Implémente une sécurité frontend renforcée.
Fonctionnalités :
- Redirection automatique si token expiré
- Accès conditionnel selon abonnement (plan actif ou non)
- Navigation dynamique : masquer les cours inaccessibles
Ajoute :
- Middleware de navigation Vue Router pour bloquer laccès aux routes protégées
- Contrôle daccès par rôle/plan dans `authStore`
- Méthodes helpers comme `canAccess(course)` ou `hasPlan(planName)`
```
---
**🧱 Bloc 7 Tableau de bord utilisateur (dashboard)**
```
Crée un dashboard utilisateur avec :
- Cours suivis et progression
- Plan actuel et facturation
- Historique des paiements (via Stripe si connecté)
- Accès aux paramètres de compte
Crée la vue `DashboardView.vue` avec :
- Composants `UserCourses.vue`, `BillingInfo.vue`, `AccountSettings.vue`
- Requêtes via les stores et services simulés
```
---
**🧱 Bloc 8 Administration des cours (interface admin)**
```
Crée une interface pour que ladministrateur puisse :
- Créer, modifier, supprimer des cours
- Ajouter des chapitres, leçons, vidéos, exercices
Crée la vue `AdminCoursesView.vue` avec :
- Liste des cours avec boutons daction
- Formulaires dynamiques pour ajouter chapitre/leçon
- Éditeur markdown intégré + upload vidéo
Utilise Pinia ou un store temporaire pour gérer les états du formulaire.
```
---
**🧱 Bloc 9 Gestion des erreurs et UX propre**
```
Ajoute une UX propre et robuste :
- Toasts pour succès/erreurs
- Indicateurs de chargement dans tous les appels API
- Pages 404 / 403 / erreur serveur
- Fallbacks pour vidéos, markdown, et éditeur de code
Ajoute un composable `useToast.ts` pour centraliser l'affichage des messages et erreurs.
```
---
Souhaites-tu que je taide à écrire le backend ou les services API de ces blocs en parallèle ? (Rails, Node.js, autre ?)
+2 -2
View File
@@ -236,8 +236,8 @@
]
}
</script>
<script defer src="https://umami.killiandalcin.fr/script.js"
data-website-id="83631152-9b6b-4724-aad1-828459ff36dc"></script>
<!-- <script defer src="https://umami.killiandalcin.fr/script.js"
data-website-id="83631152-9b6b-4724-aad1-828459ff36dc"></script> -->
</head>
<body>
+34
View File
@@ -8,7 +8,9 @@
"name": "portfolio",
"version": "0.0.0",
"dependencies": {
"@types/js-cookie": "^3.0.6",
"@vueuse/head": "^2.0.0",
"js-cookie": "^3.0.5",
"pinia": "^3.0.1",
"vue": "^3.5.13",
"vue-i18n": "^9.14.4",
@@ -105,6 +107,7 @@
"integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -2103,6 +2106,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2116,6 +2125,7 @@
"integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2166,6 +2176,7 @@
"integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.34.1",
@@ -2832,6 +2843,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3000,6 +3012,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
@@ -3382,6 +3395,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3443,6 +3457,7 @@
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -3490,6 +3505,7 @@
"integrity": "sha512-A5dRYc3eQ5i2rJFBW8J6F69ur/H7YfYg+5SCg6v829FU0BhM4fUTrRVR2d4MdZgzw0ioJEk6otYHEAnoGFqO4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"natural-compare": "^1.4.0",
@@ -4167,6 +4183,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5071,6 +5096,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -5117,6 +5143,7 @@
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -5234,6 +5261,7 @@
"integrity": "sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -5539,6 +5567,7 @@
"integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.14.0",
@@ -5590,6 +5619,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5652,6 +5682,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5767,6 +5798,7 @@
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -5945,6 +5977,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5964,6 +5997,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.17.tgz",
"integrity": "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.17",
"@vue/compiler-sfc": "3.5.17",
+2
View File
@@ -13,7 +13,9 @@
"format": "prettier --write src/"
},
"dependencies": {
"@types/js-cookie": "^3.0.6",
"@vueuse/head": "^2.0.0",
"js-cookie": "^3.0.5",
"pinia": "^3.0.1",
"vue": "^3.5.13",
"vue-i18n": "^9.14.4",
+88
View File
@@ -0,0 +1,88 @@
<template>
<div class="auth-card">
<div class="auth-card-header">
<h1 class="auth-title">{{ title }}</h1>
<p v-if="subtitle" class="auth-subtitle">{{ subtitle }}</p>
</div>
<div class="auth-card-body">
<slot />
</div>
<div v-if="$slots.footer" class="auth-card-footer">
<slot name="footer" />
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
title: string
subtitle?: string
}
defineProps<Props>()
</script>
<style scoped>
.auth-card {
background: var(--bg-primary);
border-radius: 1rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
padding: 2rem;
max-width: 400px;
width: 100%;
margin: 0 auto;
border: 1px solid var(--border-color);
}
.auth-card-header {
text-align: center;
margin-bottom: 2rem;
}
.auth-title {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 0.5rem 0;
}
.auth-subtitle {
font-size: 1rem;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.auth-card-body {
margin-bottom: 1.5rem;
}
.auth-card-footer {
text-align: center;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.auth-card {
background: var(--bg-primary-dark, #1a1a1a);
border-color: var(--border-color-dark, #333);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
}
/* Responsive */
@media (max-width: 480px) {
.auth-card {
padding: 1.5rem;
margin: 1rem;
}
.auth-title {
font-size: 1.5rem;
}
}
</style>
+177
View File
@@ -0,0 +1,177 @@
<template>
<button :type="type" :disabled="disabled || loading" :class="['form-button', variant, size, { loading }]"
@click="$emit('click')">
<div v-if="loading" class="loading-spinner">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-dasharray="31.416" stroke-dashoffset="31.416">
<animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416"
repeatCount="indefinite" />
<animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416"
repeatCount="indefinite" />
</circle>
</svg>
</div>
<span v-else class="button-content">
<slot />
</span>
</button>
</template>
<script setup lang="ts">
interface Props {
type?: 'button' | 'submit' | 'reset'
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
}
withDefaults(defineProps<Props>(), {
type: 'button',
variant: 'primary',
size: 'md',
disabled: false,
loading: false
})
defineEmits<{
click: []
}>()
</script>
<style scoped>
.form-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: none;
border-radius: 0.5rem;
font-weight: 500;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
position: relative;
overflow: hidden;
}
.form-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-button.loading {
cursor: wait;
}
/* Sizes */
.form-button.sm {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.form-button.md {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.form-button.lg {
padding: 1rem 2rem;
font-size: 1.125rem;
}
/* Variants */
.form-button.primary {
background: var(--primary-color);
color: white;
}
.form-button.primary:hover:not(:disabled) {
background: var(--primary-color-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(var(--primary-color-rgb), 0.3);
}
.form-button.secondary {
background: var(--secondary-color);
color: white;
}
.form-button.secondary:hover:not(:disabled) {
background: var(--secondary-color-dark);
transform: translateY(-1px);
}
.form-button.outline {
background: transparent;
color: var(--primary-color);
border: 2px solid var(--primary-color);
}
.form-button.outline:hover:not(:disabled) {
background: var(--primary-color);
color: white;
}
.form-button.ghost {
background: transparent;
color: var(--text-primary);
}
.form-button.ghost:hover:not(:disabled) {
background: var(--bg-secondary);
}
.form-button.danger {
background: var(--error-color);
color: white;
}
.form-button.danger:hover:not(:disabled) {
background: var(--error-color-dark);
transform: translateY(-1px);
}
/* Loading spinner */
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
}
.loading-spinner svg {
width: 1.25rem;
height: 1.25rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Button content */
.button-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Focus styles */
.form-button:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.2);
}
/* Active state */
.form-button:active:not(:disabled) {
transform: translateY(0);
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<template>
<div class="form-group">
<label v-if="label" :for="id" class="form-label">{{ label }}</label>
<div class="input-wrapper">
<input
:id="id"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:class="['form-input', { 'error': hasError }]"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur')"
@focus="$emit('focus')"
/>
<div v-if="icon" class="input-icon">
<component :is="icon" />
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="hint" class="hint-text">{{ hint }}</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
modelValue: string
label?: string
type?: 'text' | 'email' | 'password' | 'tel' | 'number'
placeholder?: string
required?: boolean
disabled?: boolean
error?: string
hint?: string
icon?: any
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
required: false,
disabled: false
})
const emit = defineEmits<{
'update:modelValue': [value: string]
'blur': []
'focus': []
}>()
const id = computed(() => `input-${Math.random().toString(36).substr(2, 9)}`)
const hasError = computed(() => !!props.error)
</script>
<style scoped>
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
}
.input-wrapper {
position: relative;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid var(--border-color);
border-radius: 0.5rem;
font-size: 1rem;
background: var(--bg-secondary);
color: var(--text-primary);
transition: all 0.2s ease;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.1);
}
.form-input.error {
border-color: var(--error-color);
}
.form-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.input-icon {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
pointer-events: none;
}
.error-message {
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--error-color);
}
.hint-text {
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.form-input {
background: var(--bg-secondary-dark, #2a2a2a);
border-color: var(--border-color-dark, #404040);
}
}
</style>
+114
View File
@@ -0,0 +1,114 @@
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
// Public routes that don't require authentication
const publicRoutes = [
'/',
'/login',
'/register',
'/forgot-password',
'/verify-email',
'/projects',
'/about',
'/contact',
'/fiverr',
'/formation'
]
// Routes that require authentication
const protectedRoutes = [
'/dashboard',
'/profile',
'/courses',
'/progress'
]
// Routes that require email verification
const verifiedEmailRoutes = [
'/dashboard',
'/courses'
]
export const requireAuth = async (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
const authStore = useAuthStore()
// Initialize auth if not already done
if (!authStore.user && authStore.token) {
await authStore.initializeAuth()
}
// Check if route requires authentication
const isProtectedRoute = protectedRoutes.some(route =>
to.path.startsWith(route)
)
if (isProtectedRoute && !authStore.isAuthenticated) {
// Redirect to login with return URL
next({
path: '/login',
query: { redirect: to.fullPath }
})
return
}
// Check if route requires email verification
const requiresEmailVerification = verifiedEmailRoutes.some(route =>
to.path.startsWith(route)
)
if (requiresEmailVerification && authStore.user && !authStore.user.isEmailVerified) {
// Redirect to email verification
next({
path: '/verify-email',
query: { email: authStore.user.email }
})
return
}
// If user is authenticated and trying to access auth pages, redirect to dashboard
if (authStore.isAuthenticated && ['/login', '/register'].includes(to.path)) {
next('/dashboard')
return
}
next()
}
export const requireGuest = (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
const authStore = useAuthStore()
if (authStore.isAuthenticated) {
next('/dashboard')
return
}
next()
}
export const requireVerifiedEmail = (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
next('/login')
return
}
if (authStore.user && !authStore.user.isEmailVerified) {
next('/verify-email')
return
}
next()
}
+58 -4
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
import { nextTick } from 'vue'
import HomePage from '../views/HomePage.vue'
import { requireAuth, requireGuest, requireVerifiedEmail } from './guards'
// Google Analytics gtag types
declare global {
@@ -48,6 +49,58 @@ const router = createRouter({
name: 'formation',
component: () => import('../views/FormationPage.vue')
},
// Auth routes
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
beforeEnter: requireGuest,
meta: {
title: 'Connexion - Plateforme de Formation',
description: 'Connectez-vous à votre compte pour accéder à vos formations en développement web.'
}
},
{
path: '/register',
name: 'register',
component: () => import('../views/RegisterView.vue'),
beforeEnter: requireGuest,
meta: {
title: 'Inscription - Plateforme de Formation',
description: 'Créez votre compte pour accéder à nos formations en développement web.'
}
},
{
path: '/forgot-password',
name: 'forgot-password',
component: () => import('../views/ForgotPasswordView.vue'),
beforeEnter: requireGuest,
meta: {
title: 'Mot de passe oublié - Plateforme de Formation',
description: 'Réinitialisez votre mot de passe pour accéder à votre compte.'
}
},
{
path: '/verify-email',
name: 'verify-email',
component: () => import('../views/VerifyEmailView.vue'),
meta: {
title: 'Vérification Email - Plateforme de Formation',
description: 'Vérifiez votre adresse email pour activer votre compte et accéder aux formations.'
}
},
// Protected routes
{
path: '/dashboard',
name: 'dashboard',
component: () => import('../views/DashboardView.vue'),
beforeEnter: requireVerifiedEmail,
meta: {
title: 'Tableau de bord - Plateforme de Formation',
description: 'Accédez à vos formations et suivez votre progression.',
requiresAuth: true
}
},
// TODO: page 404
{
path: '/:pathMatch(.*)*',
@@ -81,8 +134,11 @@ const router = createRouter({
}
})
// SEO Meta tags handler
router.beforeEach((to, from, next) => {
// Global navigation guard
router.beforeEach(async (to, from, next) => {
// Handle authentication
await requireAuth(to, from, next)
// Update document title
if (to.meta?.title) {
document.title = to.meta.title as string
@@ -98,8 +154,6 @@ router.beforeEach((to, from, next) => {
}
metaDescription.setAttribute('content', to.meta.description as string)
}
next()
})
// Google Analytics page view tracking function
+156
View File
@@ -0,0 +1,156 @@
import type {
LoginCredentials,
RegisterCredentials,
AuthResponse,
User,
ForgotPasswordRequest,
ResetPasswordRequest,
VerifyEmailRequest
} from '@/types/auth'
// Mock data for development
const mockUsers: User[] = [
{
id: '1',
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
plan: 'pro',
isEmailVerified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
]
const mockTokens: Record<string, string> = {
'test@example.com': 'mock-jwt-token-12345'
}
// Simulate API delay
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
class AuthService {
private baseURL = '/api/auth'
// Simulate API call
private async mockApiCall<T>(data: T, success = true, delayMs = 1000): Promise<T> {
await delay(delayMs)
if (!success) {
throw new Error('API Error')
}
return data
}
async login(credentials: LoginCredentials): Promise<AuthResponse> {
// Simulate validation
const user = mockUsers.find(u => u.email === credentials.email)
if (!user || credentials.password !== 'password') {
throw new Error('Email ou mot de passe incorrect')
}
const token = mockTokens[credentials.email] || 'mock-jwt-token'
return this.mockApiCall({
user,
token,
refreshToken: 'mock-refresh-token'
})
}
async register(credentials: RegisterCredentials): Promise<AuthResponse> {
// Simulate validation
if (credentials.password !== credentials.confirmPassword) {
throw new Error('Les mots de passe ne correspondent pas')
}
if (credentials.password.length < 6) {
throw new Error('Le mot de passe doit contenir au moins 6 caractères')
}
// Check if user already exists
if (mockUsers.find(u => u.email === credentials.email)) {
throw new Error('Un compte avec cet email existe déjà')
}
const newUser: User = {
id: Date.now().toString(),
email: credentials.email,
firstName: credentials.firstName,
lastName: credentials.lastName,
plan: credentials.plan,
isEmailVerified: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
const token = `mock-jwt-token-${Date.now()}`
mockTokens[credentials.email] = token
mockUsers.push(newUser)
return this.mockApiCall({
user: newUser,
token,
refreshToken: 'mock-refresh-token'
})
}
async logout(): Promise<void> {
return this.mockApiCall(undefined, true, 500)
}
async forgotPassword(request: ForgotPasswordRequest): Promise<void> {
// Simulate email validation
if (!request.email || !request.email.includes('@')) {
throw new Error('Email invalide')
}
return this.mockApiCall(undefined, true, 800)
}
async resetPassword(request: ResetPasswordRequest): Promise<void> {
// Simulate validation
if (request.password !== request.confirmPassword) {
throw new Error('Les mots de passe ne correspondent pas')
}
if (request.password.length < 6) {
throw new Error('Le mot de passe doit contenir au moins 6 caractères')
}
if (!request.token) {
throw new Error('Token invalide')
}
return this.mockApiCall(undefined, true, 1000)
}
async verifyEmail(request: VerifyEmailRequest): Promise<void> {
if (!request.token) {
throw new Error('Token invalide')
}
return this.mockApiCall(undefined, true, 800)
}
async refreshToken(): Promise<AuthResponse> {
// Simulate token refresh
const user = mockUsers[0] // Use first user for demo
const token = `mock-jwt-token-refreshed-${Date.now()}`
return this.mockApiCall({
user,
token,
refreshToken: 'mock-refresh-token-new'
}, true, 500)
}
async getCurrentUser(): Promise<User | null> {
// Simulate getting current user from token
return this.mockApiCall(mockUsers[0] || null, true, 300)
}
}
export const authService = new AuthService()
+210
View File
@@ -0,0 +1,210 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { authService } from '@/services/authService'
import type {
User,
LoginCredentials,
RegisterCredentials,
ForgotPasswordRequest,
ResetPasswordRequest,
VerifyEmailRequest,
AuthState
} from '@/types/auth'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('auth_token'))
const isLoading = ref(false)
const error = ref<string | null>(null)
// Computed
const isAuthenticated = computed(() => !!user.value && !!token.value)
// Actions
const setToken = (newToken: string | null) => {
token.value = newToken
if (newToken) {
localStorage.setItem('auth_token', newToken)
} else {
localStorage.removeItem('auth_token')
}
}
const setUser = (newUser: User | null) => {
user.value = newUser
}
const setError = (newError: string | null) => {
error.value = newError
}
const clearError = () => {
error.value = null
}
const login = async (credentials: LoginCredentials) => {
try {
isLoading.value = true
clearError()
const response = await authService.login(credentials)
setUser(response.user)
setToken(response.token)
return response
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Erreur de connexion'
setError(errorMessage)
throw err
} finally {
isLoading.value = false
}
}
const register = async (credentials: RegisterCredentials) => {
try {
isLoading.value = true
clearError()
const response = await authService.register(credentials)
setUser(response.user)
setToken(response.token)
return response
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Erreur d\'inscription'
setError(errorMessage)
throw err
} finally {
isLoading.value = false
}
}
const logout = async () => {
try {
isLoading.value = true
await authService.logout()
} catch (err) {
console.error('Logout error:', err)
} finally {
setUser(null)
setToken(null)
isLoading.value = false
}
}
const forgotPassword = async (request: ForgotPasswordRequest) => {
try {
isLoading.value = true
clearError()
await authService.forgotPassword(request)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Erreur lors de l\'envoi de l\'email'
setError(errorMessage)
throw err
} finally {
isLoading.value = false
}
}
const resetPassword = async (request: ResetPasswordRequest) => {
try {
isLoading.value = true
clearError()
await authService.resetPassword(request)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Erreur lors de la réinitialisation du mot de passe'
setError(errorMessage)
throw err
} finally {
isLoading.value = false
}
}
const verifyEmail = async (request: VerifyEmailRequest) => {
try {
isLoading.value = true
clearError()
await authService.verifyEmail(request)
// Update user verification status
if (user.value) {
user.value.isEmailVerified = true
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Erreur lors de la vérification de l\'email'
setError(errorMessage)
throw err
} finally {
isLoading.value = false
}
}
const refreshToken = async () => {
try {
const response = await authService.refreshToken()
setUser(response.user)
setToken(response.token)
return response
} catch (err) {
console.error('Token refresh error:', err)
// If refresh fails, logout user
await logout()
throw err
}
}
const getCurrentUser = async () => {
try {
if (!token.value) return null
const currentUser = await authService.getCurrentUser()
if (currentUser) {
setUser(currentUser)
}
return currentUser
} catch (err) {
console.error('Get current user error:', err)
// If getting user fails, logout
await logout()
return null
}
}
const initializeAuth = async () => {
if (token.value) {
await getCurrentUser()
}
}
return {
// State
user,
token,
isLoading,
error,
// Computed
isAuthenticated,
// Actions
login,
register,
logout,
forgotPassword,
resetPassword,
verifyEmail,
refreshToken,
getCurrentUser,
initializeAuth,
setError,
clearError
}
})
+52
View File
@@ -0,0 +1,52 @@
export interface User {
id: string
email: string
firstName?: string
lastName?: string
plan?: string
isEmailVerified: boolean
createdAt: string
updatedAt: string
}
export interface LoginCredentials {
email: string
password: string
}
export interface RegisterCredentials {
email: string
password: string
confirmPassword: string
firstName?: string
lastName?: string
plan?: string
}
export interface AuthResponse {
user: User
token: string
refreshToken?: string
}
export interface ForgotPasswordRequest {
email: string
}
export interface ResetPasswordRequest {
token: string
password: string
confirmPassword: string
}
export interface VerifyEmailRequest {
token: string
}
export interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
}
+293
View File
@@ -0,0 +1,293 @@
<template>
<div class="dashboard-page page-enter">
<div class="container">
<header class="dashboard-header">
<h1 class="dashboard-title">Tableau de bord</h1>
<div class="user-info">
<span>Bienvenue, {{ authStore.user?.firstName || authStore.user?.email }}</span>
<FormButton variant="outline" size="sm" @click="handleLogout">
Déconnexion
</FormButton>
</div>
</header>
<div class="dashboard-content">
<div class="welcome-card">
<h2>Bienvenue sur votre plateforme de formation !</h2>
<p>
Vous êtes maintenant connecté avec le plan
<strong>{{ getPlanName(authStore.user?.plan) }}</strong>
</p>
<p v-if="authStore.user?.isEmailVerified" class="verified-badge">
Email vérifié
</p>
<p v-else class="unverified-badge">
Email non vérifié
</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>Cours disponibles</h3>
<div class="stat-value">12</div>
<p>Cours en développement web</p>
</div>
<div class="stat-card">
<h3>Progression</h3>
<div class="stat-value">0%</div>
<p>Commencez votre premier cours</p>
</div>
<div class="stat-card">
<h3>Certificats</h3>
<div class="stat-value">0</div>
<p>Certificats obtenus</p>
</div>
</div>
<div class="quick-actions">
<h3>Actions rapides</h3>
<div class="actions-grid">
<button class="action-button">
<span>📚</span>
Parcourir les cours
</button>
<button class="action-button">
<span>🎯</span>
Voir mes objectifs
</button>
<button class="action-button">
<span>👥</span>
Communauté
</button>
<button class="action-button">
<span></span>
Paramètres
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import FormButton from '@/components/ui/FormButton.vue'
import { useSeo } from '@/composables/useSeo'
// SEO
useSeo({
title: 'Tableau de bord - Plateforme de Formation',
description: 'Accédez à vos formations et suivez votre progression en développement web.',
keywords: 'tableau de bord, formation, progression, cours développement web'
})
const router = useRouter()
const authStore = useAuthStore()
// Get plan name
const getPlanName = (planId?: string) => {
const plans: Record<string, string> = {
starter: 'Starter',
pro: 'Pro',
expert: 'Expert'
}
return plans[planId || ''] || 'Inconnu'
}
// Handle logout
const handleLogout = async () => {
await authStore.logout()
router.push('/')
}
</script>
<style scoped>
.dashboard-page {
min-height: 100vh;
background: var(--bg-primary);
padding: 2rem 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.dashboard-title {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
color: var(--text-secondary);
}
.dashboard-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.welcome-card {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
padding: 2rem;
border-radius: 1rem;
text-align: center;
}
.welcome-card h2 {
font-size: 1.5rem;
margin: 0 0 1rem 0;
}
.welcome-card p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
.verified-badge {
color: #10b981;
font-weight: 600;
}
.unverified-badge {
color: #f59e0b;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.stat-card {
background: var(--bg-secondary);
padding: 1.5rem;
border-radius: 0.75rem;
border: 1px solid var(--border-color);
text-align: center;
}
.stat-card h3 {
font-size: 1rem;
color: var(--text-secondary);
margin: 0 0 0.5rem 0;
font-weight: 500;
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--primary-color);
margin: 0.5rem 0;
}
.stat-card p {
color: var(--text-secondary);
margin: 0;
font-size: 0.875rem;
}
.quick-actions {
margin-top: 1rem;
}
.quick-actions h3 {
font-size: 1.25rem;
color: var(--text-primary);
margin: 0 0 1rem 0;
}
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.action-button {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
}
.action-button:hover {
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.action-button span {
font-size: 2rem;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.stat-card,
.action-button {
background: var(--bg-secondary-dark, #2a2a2a);
border-color: var(--border-color-dark, #404040);
}
}
/* Responsive */
@media (max-width: 768px) {
.dashboard-header {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.user-info {
flex-direction: column;
gap: 0.5rem;
}
.stats-grid {
grid-template-columns: 1fr;
}
.actions-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.container {
padding: 0 1rem;
}
.actions-grid {
grid-template-columns: 1fr;
}
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<template>
<div class="forgot-password-page page-enter">
<div class="auth-container">
<AuthCard
title="Mot de passe oublié"
subtitle="Entrez votre email pour recevoir un lien de réinitialisation"
>
<form @submit.prevent="handleForgotPassword" class="auth-form">
<FormInput
v-model="form.email"
type="email"
label="Email"
placeholder="votre@email.com"
required
:error="errors.email"
/>
<div class="form-actions">
<FormButton
type="submit"
variant="primary"
size="lg"
:loading="authStore.isLoading"
class="submit-button"
>
Envoyer le lien
</FormButton>
</div>
</form>
<template #footer>
<div class="auth-links">
<router-link to="/login" class="auth-link">
Retour à la connexion
</router-link>
</div>
</template>
</AuthCard>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AuthCard from '@/components/ui/AuthCard.vue'
import FormInput from '@/components/ui/FormInput.vue'
import FormButton from '@/components/ui/FormButton.vue'
import { useSeo } from '@/composables/useSeo'
// SEO
useSeo({
title: 'Mot de passe oublié - Plateforme de Formation',
description: 'Réinitialisez votre mot de passe pour accéder à votre compte.',
keywords: 'mot de passe oublié, réinitialisation, authentification'
})
const router = useRouter()
const authStore = useAuthStore()
// Form data
const form = reactive({
email: ''
})
// Form errors
const errors = reactive({
email: ''
})
// Validation
const validateForm = () => {
errors.email = ''
if (!form.email) {
errors.email = 'L\'email est requis'
return false
}
if (!form.email.includes('@')) {
errors.email = 'L\'email n\'est pas valide'
return false
}
return true
}
// Handle forgot password
const handleForgotPassword = async () => {
if (!validateForm()) return
try {
await authStore.forgotPassword({
email: form.email
})
// Show success message or redirect
alert('Un email de réinitialisation a été envoyé à votre adresse email.')
router.push('/login')
} catch (error) {
// Error is handled by the store
console.error('Forgot password error:', error)
}
}
</script>
<style scoped>
.forgot-password-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
padding: 2rem 1rem;
}
.auth-container {
width: 100%;
max-width: 500px;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-actions {
margin-top: 1rem;
}
.submit-button {
width: 100%;
}
.auth-links {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.auth-link {
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.auth-link:hover {
color: var(--primary-color-dark);
text-decoration: underline;
}
/* Responsive */
@media (max-width: 480px) {
.forgot-password-page {
padding: 1rem;
}
}
</style>
+3 -2
View File
@@ -56,9 +56,10 @@
</ul>
<div class="card-actions">
<button :class="['cta-button', plan.popular ? 'primary' : 'secondary']">
<router-link :to="{ name: 'register', query: { plan: plan.id } }"
:class="['cta-button', plan.popular ? 'primary' : 'secondary']">
{{ $t('pricing.startTrial') }}
</button>
</router-link>
<p class="trial-info">{{ $t('pricing.trialInfo') }}</p>
</div>
</div>
+203
View File
@@ -0,0 +1,203 @@
<template>
<div class="login-page page-enter">
<div class="auth-container">
<AuthCard title="Connexion" subtitle="Connectez-vous à votre compte pour accéder à vos formations">
<form @submit.prevent="handleLogin" class="auth-form">
<FormInput v-model="form.email" type="email" label="Email" placeholder="votre@email.com" required
:error="errors.email" />
<FormInput v-model="form.password" type="password" label="Mot de passe"
placeholder="Votre mot de passe" required :error="errors.password" />
<div class="form-actions">
<FormButton type="submit" variant="primary" size="lg" :loading="authStore.isLoading"
class="submit-button">
Se connecter
</FormButton>
</div>
</form>
<template #footer>
<div class="auth-links">
<router-link to="/forgot-password" class="auth-link">
Mot de passe oublié ?
</router-link>
<div class="auth-divider">
<span>ou</span>
</div>
<router-link to="/register" class="auth-link">
Créer un compte
</router-link>
</div>
</template>
</AuthCard>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AuthCard from '@/components/ui/AuthCard.vue'
import FormInput from '@/components/ui/FormInput.vue'
import FormButton from '@/components/ui/FormButton.vue'
import { useSeo } from '@/composables/useSeo'
// SEO
useSeo({
title: 'Connexion - Plateforme de Formation',
description: 'Connectez-vous à votre compte pour accéder à vos formations en développement web.',
keywords: 'connexion, authentification, formation développement web'
})
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// Form data
const form = reactive({
email: '',
password: ''
})
// Form errors
const errors = reactive({
email: '',
password: ''
})
// Validation
const validateForm = () => {
errors.email = ''
errors.password = ''
if (!form.email) {
errors.email = 'L\'email est requis'
return false
}
if (!form.email.includes('@')) {
errors.email = 'L\'email n\'est pas valide'
return false
}
if (!form.password) {
errors.password = 'Le mot de passe est requis'
return false
}
if (form.password.length < 6) {
errors.password = 'Le mot de passe doit contenir au moins 6 caractères'
return false
}
return true
}
// Handle login
const handleLogin = async () => {
if (!validateForm()) return
try {
await authStore.login({
email: form.email,
password: form.password
})
// Redirect to intended page or dashboard
const redirectPath = route.query.redirect as string || '/dashboard'
router.push(redirectPath)
} catch (error) {
// Error is handled by the store
console.error('Login error:', error)
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
padding: 2rem 1rem;
}
.auth-container {
width: 100%;
max-width: 500px;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-actions {
margin-top: 1rem;
}
.submit-button {
width: 100%;
}
.auth-links {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.auth-link {
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.auth-link:hover {
color: var(--primary-color-dark);
text-decoration: underline;
}
.auth-divider {
position: relative;
width: 100%;
text-align: center;
margin: 0.5rem 0;
}
.auth-divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: var(--border-color);
}
.auth-divider span {
background: var(--bg-primary);
padding: 0 1rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.auth-divider span {
background: var(--bg-primary-dark, #1a1a1a);
}
}
/* Responsive */
@media (max-width: 480px) {
.login-page {
padding: 1rem;
}
}
</style>
+372
View File
@@ -0,0 +1,372 @@
<template>
<div class="register-page page-enter">
<div class="auth-container">
<AuthCard title="Créer un compte" subtitle="Rejoignez notre plateforme de formation en développement web">
<form @submit.prevent="handleRegister" class="auth-form">
<div class="name-fields">
<FormInput v-model="form.firstName" type="text" label="Prénom" placeholder="Votre prénom"
:error="errors.firstName" />
<FormInput v-model="form.lastName" type="text" label="Nom" placeholder="Votre nom"
:error="errors.lastName" />
</div>
<FormInput v-model="form.email" type="email" label="Email" placeholder="votre@email.com" required
:error="errors.email" />
<FormInput v-model="form.password" type="password" label="Mot de passe" placeholder="Votre mot de passe"
required :error="errors.password" hint="Au moins 6 caractères" />
<FormInput v-model="form.confirmPassword" type="password" label="Confirmer le mot de passe"
placeholder="Confirmez votre mot de passe" required :error="errors.confirmPassword" />
<div class="plan-selection">
<label class="plan-label">Plan d'abonnement</label>
<div class="plan-options">
<label v-for="plan in plans" :key="plan.id" :class="['plan-option', { selected: form.plan === plan.id }]">
<input type="radio" :value="plan.id" v-model="form.plan" class="plan-radio" />
<div class="plan-info">
<div class="plan-name">{{ plan.name }}</div>
<div class="plan-price">{{ plan.price }}€/mois</div>
</div>
</label>
</div>
<div v-if="errors.plan" class="error-message">{{ errors.plan }}</div>
</div>
<div class="form-actions">
<FormButton type="submit" variant="primary" size="lg" :loading="authStore.isLoading" class="submit-button">
Créer mon compte
</FormButton>
</div>
</form>
<template #footer>
<div class="auth-links">
<p class="terms-text">
En créant un compte, vous acceptez nos
<a href="/terms" class="auth-link">conditions d'utilisation</a> et notre
<a href="/privacy" class="auth-link">politique de confidentialité</a>
</p>
<div class="auth-divider">
<span>ou</span>
</div>
<router-link to="/login" class="auth-link">
Déjà un compte ? Se connecter
</router-link>
</div>
</template>
</AuthCard>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AuthCard from '@/components/ui/AuthCard.vue'
import FormInput from '@/components/ui/FormInput.vue'
import FormButton from '@/components/ui/FormButton.vue'
import { useSeo } from '@/composables/useSeo'
// SEO
useSeo({
title: 'Inscription - Plateforme de Formation',
description: 'Créez votre compte pour accéder à nos formations en développement web.',
keywords: 'inscription, créer compte, formation développement web'
})
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// Available plans
const plans = [
{ id: 'starter', name: 'Starter', price: 29 },
{ id: 'pro', name: 'Pro', price: 59 },
{ id: 'expert', name: 'Expert', price: 99 }
]
// Form data
const form = reactive({
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
plan: 'pro'
})
// Set plan from URL query if provided
onMounted(() => {
if (route.query.plan && typeof route.query.plan === 'string') {
const planId = route.query.plan
if (plans.some(plan => plan.id === planId)) {
form.plan = planId
}
}
})
// Form errors
const errors = reactive({
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
plan: ''
})
// Validation
const validateForm = () => {
errors.firstName = ''
errors.lastName = ''
errors.email = ''
errors.password = ''
errors.confirmPassword = ''
errors.plan = ''
if (!form.firstName.trim()) {
errors.firstName = 'Le prénom est requis'
return false
}
if (!form.lastName.trim()) {
errors.lastName = 'Le nom est requis'
return false
}
if (!form.email) {
errors.email = 'L\'email est requis'
return false
}
if (!form.email.includes('@')) {
errors.email = 'L\'email n\'est pas valide'
return false
}
if (!form.password) {
errors.password = 'Le mot de passe est requis'
return false
}
if (form.password.length < 6) {
errors.password = 'Le mot de passe doit contenir au moins 6 caractères'
return false
}
if (!form.confirmPassword) {
errors.confirmPassword = 'La confirmation du mot de passe est requise'
return false
}
if (form.password !== form.confirmPassword) {
errors.confirmPassword = 'Les mots de passe ne correspondent pas'
return false
}
if (!form.plan) {
errors.plan = 'Veuillez sélectionner un plan'
return false
}
return true
}
// Handle register
const handleRegister = async () => {
if (!validateForm()) return
try {
await authStore.register({
email: form.email,
password: form.password,
confirmPassword: form.confirmPassword,
firstName: form.firstName,
lastName: form.lastName,
plan: form.plan
})
// Redirect to email verification page
router.push('/verify-email')
} catch (error) {
// Error is handled by the store
console.error('Register error:', error)
}
}
</script>
<style scoped>
.register-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
padding: 2rem 1rem;
}
.auth-container {
width: 100%;
max-width: 500px;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.name-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.plan-selection {
margin-top: 1rem;
}
.plan-label {
display: block;
margin-bottom: 0.75rem;
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
}
.plan-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.plan-option {
display: flex;
align-items: center;
padding: 1rem;
border: 2px solid var(--border-color);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
background: var(--bg-secondary);
}
.plan-option:hover {
border-color: var(--primary-color);
}
.plan-option.selected {
border-color: var(--primary-color);
background: rgba(var(--primary-color-rgb), 0.05);
}
.plan-radio {
margin-right: 1rem;
accent-color: var(--primary-color);
}
.plan-info {
flex: 1;
}
.plan-name {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.plan-price {
font-size: 0.875rem;
color: var(--text-secondary);
}
.form-actions {
margin-top: 1rem;
}
.submit-button {
width: 100%;
}
.auth-links {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.terms-text {
font-size: 0.875rem;
color: var(--text-secondary);
text-align: center;
line-height: 1.5;
margin: 0;
}
.auth-link {
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.auth-link:hover {
color: var(--primary-color-dark);
text-decoration: underline;
}
.auth-divider {
position: relative;
width: 100%;
text-align: center;
margin: 0.5rem 0;
}
.auth-divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: var(--border-color);
}
.auth-divider span {
background: var(--bg-primary);
padding: 0 1rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.error-message {
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--error-color);
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.auth-divider span {
background: var(--bg-primary-dark, #1a1a1a);
}
.plan-option {
background: var(--bg-secondary-dark, #2a2a2a);
}
}
/* Responsive */
@media (max-width: 480px) {
.register-page {
padding: 1rem;
}
.name-fields {
grid-template-columns: 1fr;
}
}
</style>
+244
View File
@@ -0,0 +1,244 @@
<template>
<div class="verify-email-page page-enter">
<div class="auth-container">
<AuthCard title="Vérification de l'email" subtitle="Vérifiez votre adresse email pour activer votre compte">
<div class="verify-content">
<div class="email-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4 4H20C21.1 4 22 4.9 22 6V18C22 19.1 21.1 20 20 20H4C2.9 20 2 19.1 2 18V6C2 4.9 2.9 4 4 4Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<polyline points="22,6 12,13 2,6" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<div class="verify-message">
<h3>Vérifiez votre boîte mail</h3>
<p>
Nous avons envoyé un email de vérification à
<strong>{{ userEmail }}</strong>
</p>
<p>
Cliquez sur le lien dans l'email pour activer votre compte et commencer
votre formation en développement web.
</p>
</div>
<div class="verify-actions">
<FormButton variant="outline" size="lg" :loading="authStore.isLoading" @click="resendEmail">
Renvoyer l'email
</FormButton>
<FormButton variant="primary" size="lg" @click="checkVerification">
J'ai vérifié mon email
</FormButton>
</div>
<div class="verify-tips">
<h4>Conseils :</h4>
<ul>
<li>Vérifiez votre dossier spam si vous ne trouvez pas l'email</li>
<li>Assurez-vous d'avoir saisi la bonne adresse email</li>
<li>L'email peut prendre quelques minutes à arriver</li>
</ul>
</div>
</div>
<template #footer>
<div class="auth-links">
<router-link to="/login" class="auth-link">
Retour à la connexion
</router-link>
</div>
</template>
</AuthCard>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AuthCard from '@/components/ui/AuthCard.vue'
import FormButton from '@/components/ui/FormButton.vue'
import { useSeo } from '@/composables/useSeo'
// SEO
useSeo({
title: 'Vérification Email - Plateforme de Formation',
description: 'Vérifiez votre adresse email pour activer votre compte et accéder aux formations.',
keywords: 'vérification email, activation compte, formation développement web'
})
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const userEmail = ref('')
// Get user email from store or route
onMounted(() => {
if (authStore.user?.email) {
userEmail.value = authStore.user.email
} else if (route.query.email) {
userEmail.value = route.query.email as string
} else {
// Redirect to login if no email found
router.push('/login')
}
})
// Resend verification email
const resendEmail = async () => {
try {
await authStore.verifyEmail({ token: 'mock-token' })
alert('Email de vérification renvoyé avec succès !')
} catch (error) {
console.error('Resend email error:', error)
}
}
// Check if email is verified
const checkVerification = async () => {
try {
// In a real app, this would check the verification status
// For now, we'll simulate success
if (authStore.user) {
authStore.user.isEmailVerified = true
}
alert('Email vérifié avec succès !')
router.push('/dashboard')
} catch (error) {
console.error('Verification check error:', error)
}
}
</script>
<style scoped>
.verify-email-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
padding: 2rem 1rem;
}
.auth-container {
width: 100%;
max-width: 500px;
}
.verify-content {
text-align: center;
}
.email-icon {
margin-bottom: 2rem;
}
.email-icon svg {
width: 64px;
height: 64px;
color: var(--primary-color);
}
.verify-message {
margin-bottom: 2rem;
}
.verify-message h3 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 1rem 0;
}
.verify-message p {
color: var(--text-secondary);
line-height: 1.6;
margin: 0 0 1rem 0;
}
.verify-message strong {
color: var(--text-primary);
}
.verify-actions {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
}
.verify-tips {
text-align: left;
background: var(--bg-secondary);
padding: 1.5rem;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
}
.verify-tips h4 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 1rem 0;
}
.verify-tips ul {
margin: 0;
padding-left: 1.5rem;
color: var(--text-secondary);
}
.verify-tips li {
margin-bottom: 0.5rem;
line-height: 1.5;
}
.auth-links {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.auth-link {
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.auth-link:hover {
color: var(--primary-color-dark);
text-decoration: underline;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.verify-tips {
background: var(--bg-secondary-dark, #2a2a2a);
border-color: var(--border-color-dark, #404040);
}
}
/* Responsive */
@media (max-width: 480px) {
.verify-email-page {
padding: 1rem;
}
.verify-actions {
gap: 0.75rem;
}
.verify-tips {
padding: 1rem;
}
}
</style>
+16 -6
View File
@@ -2,14 +2,23 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
export default defineConfig(async ({ command }) => {
// `vite-plugin-vue-devtools` (via @vue/devtools-kit) can access `localStorage`
// at import time, which crashes when Vite loads this config in Node.
// Load it lazily and only for the dev server.
const plugins = [vue()]
// Opt-in only: enable by running `VITE_VUE_DEVTOOLS=true npm run dev`
// This avoids Node-side crashes when loading the Vite config.
if (command === 'serve' && process.env.VITE_VUE_DEVTOOLS === 'true') {
const { default: vueDevTools } = await import('vite-plugin-vue-devtools')
plugins.push(vueDevTools())
}
return {
plugins,
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
@@ -51,4 +60,5 @@ export default defineConfig({
optimizeDeps: {
include: ['vue', 'vue-router']
}
}
})