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:
+180
@@ -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 l’authentification côté frontend (Vue 3 + TypeScript).
|
||||
|
||||
Fonctionnalités :
|
||||
- Inscription (email, mot de passe, plan choisi — non fonctionnel pour l’instant)
|
||||
- Connexion avec token (JWT ou cookie, à simuler)
|
||||
- Déconnexion
|
||||
- Mot de passe oublié
|
||||
- Vérification d’email (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 d’abonnement 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 "S’abonner"
|
||||
- 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 d’un client Stripe à l’inscription
|
||||
- Webhooks Stripe pour gérer statut de paiement
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**🧱 Bloc 3 – Architecture des cours (textes + vidéos + code)**
|
||||
|
||||
```
|
||||
Crée la structure d’un 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 l’instant) :
|
||||
- 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 l’utilisateur.
|
||||
|
||||
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 l’instant)
|
||||
|
||||
Implémente :
|
||||
- Un store Pinia `useProgressStore`
|
||||
- Une structure : { user_id, course_id, completed_lessons: [lesson_ids] }
|
||||
- Affiche des badges de progression dans l’UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**🧱 Bloc 5 – Éditeur de code avec exécution sandboxée**
|
||||
|
||||
```
|
||||
Ajoute une zone d’exé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 d’exécution (Docker, VM, ou service type Piston ou Playground)
|
||||
- Récupération de l’output 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 l’accès aux routes protégées
|
||||
- Contrôle d’accè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 l’administrateur 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 d’action
|
||||
- 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 t’aide à écrire le backend ou les services API de ces blocs en parallèle ? (Rails, Node.js, autre ?)
|
||||
+2
-2
@@ -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>
|
||||
|
||||
Generated
+34
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user