diff --git a/GUIDE_DEPLOIEMENT_LINUX.md b/GUIDE_DEPLOIEMENT_LINUX.md new file mode 100644 index 0000000..ff2ac1e --- /dev/null +++ b/GUIDE_DEPLOIEMENT_LINUX.md @@ -0,0 +1,600 @@ +# Guide de déploiement - Formation Manager Itinova sur serveur Linux + +## Vue d'ensemble + +Ce guide vous accompagne dans le déploiement de l'application Formation Manager Itinova sur un serveur Linux (Ubuntu/Debian) avec authentification locale email/mot de passe (sans OAuth Manus). + +**Durée estimée** : 1-2 heures +**Niveau** : Intermédiaire + +--- + +## Prérequis + +### Logiciels requis + +- **Système** : Ubuntu 22.04 LTS ou Debian 11+ +- **Node.js** : Version 22.x +- **pnpm** : Version 10.x +- **MySQL** : Version 8.0+ ou MariaDB 10.6+ +- **Nginx** : Pour le reverse proxy (optionnel mais recommandé) + +### Accès nécessaires + +- Accès SSH au serveur +- Droits sudo +- Accès à la base de données MySQL + +--- + +## Étape 1 : Préparation du serveur + +### 1.1 Mise à jour du système + +```bash +sudo apt update +sudo apt upgrade -y +``` + +### 1.2 Installation de Node.js 22.x + +```bash +# Installer Node.js via NodeSource +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt install -y nodejs + +# Vérifier l'installation +node --version # Doit afficher v22.x.x +``` + +### 1.3 Installation de pnpm + +```bash +npm install -g pnpm +pnpm --version # Doit afficher 10.x.x +``` + +### 1.4 Installation de MySQL + +```bash +sudo apt install -y mysql-server +sudo mysql_secure_installation +``` + +--- + +## Étape 2 : Configuration de la base de données + +### 2.1 Créer la base de données et l'utilisateur + +```bash +sudo mysql +``` + +Dans MySQL : + +```sql +CREATE DATABASE formation_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'formation_user'@'localhost' IDENTIFIED BY 'VOTRE_MOT_DE_PASSE_SECURISE'; +GRANT ALL PRIVILEGES ON formation_manager.* TO 'formation_user'@'localhost'; +FLUSH PRIVILEGES; +EXIT; +``` + +### 2.2 Tester la connexion + +```bash +mysql -u formation_user -p formation_manager +# Entrez le mot de passe, puis EXIT +``` + +--- + +## Étape 3 : Déploiement de l'application + +### 3.1 Créer le répertoire de l'application + +```bash +sudo mkdir -p /var/www/formation-manager-itinova +sudo chown -R $USER:$USER /var/www/formation-manager-itinova +cd /var/www/formation-manager-itinova +``` + +### 3.2 Cloner ou copier les fichiers + +**Option A : Via Git (recommandé)** + +```bash +git clone https://github.com/votre-repo/formation-manager-itinova.git . +``` + +**Option B : Via upload manuel** + +Transférez les fichiers via SCP/SFTP vers `/var/www/formation-manager-itinova/` + +### 3.3 Installer les dépendances + +```bash +cd /var/www/formation-manager-itinova +pnpm install +``` + +--- + +## Étape 4 : Configuration de l'application + +### 4.1 Créer le fichier .env + +```bash +nano /var/www/formation-manager-itinova/.env +``` + +**Contenu du fichier .env :** + +```env +# Base de données +DATABASE_URL=mysql://formation_user:VOTRE_MOT_DE_PASSE@localhost:3306/formation_manager + +# Sécurité JWT (générez une chaîne aléatoire longue) +JWT_SECRET=CHANGEZ_CECI_PAR_UNE_CHAINE_ALEATOIRE_TRES_LONGUE_ET_SECURISEE + +# Configuration de l'application +NODE_ENV=production +VITE_APP_TITLE=Formation Manager Itinova +VITE_APP_LOGO=/logo.svg + +# Email (optionnel - pour Resend) +RESEND_API_KEY= +RESEND_FROM_EMAIL= + +# Port de l'application (par défaut 3000) +PORT=3000 +``` + +**Générer un JWT_SECRET sécurisé :** + +```bash +openssl rand -base64 64 +``` + +Copiez le résultat dans `JWT_SECRET`. + +### 4.2 Sécuriser le fichier .env + +```bash +chmod 600 /var/www/formation-manager-itinova/.env +``` + +### 4.3 Initialiser la base de données + +```bash +cd /var/www/formation-manager-itinova +pnpm db:push +``` + +Cette commande va créer toutes les tables nécessaires. + +--- + +## Étape 5 : Compilation de l'application + +### 5.1 Compiler le frontend et le backend + +```bash +cd /var/www/formation-manager-itinova +pnpm build +``` + +### 5.2 Vérifier que la compilation a réussi + +```bash +ls -la /var/www/formation-manager-itinova/dist/ +``` + +Vous devriez voir un fichier `index.js`. + +--- + +## Étape 6 : Configuration du service systemd + +### 6.1 Créer le fichier de service + +```bash +sudo nano /etc/systemd/system/formation-manager.service +``` + +**Contenu du fichier :** + +```ini +[Unit] +Description=Formation Manager Itinova +After=network.target mysql.service + +[Service] +Type=simple +User=www-data +WorkingDirectory=/var/www/formation-manager-itinova +Environment="NODE_ENV=production" +Environment="HOME=/var/www/formation-manager-itinova" +Environment="PNPM_HOME=/var/www/formation-manager-itinova/.pnpm" +EnvironmentFile=/var/www/formation-manager-itinova/.env +ExecStart=/usr/bin/pnpm start +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +``` + +### 6.2 Configurer les permissions + +```bash +sudo chown -R www-data:www-data /var/www/formation-manager-itinova +sudo mkdir -p /var/www/.local/share/pnpm +sudo chown -R www-data:www-data /var/www/.local +``` + +### 6.3 Activer et démarrer le service + +```bash +sudo systemctl daemon-reload +sudo systemctl enable formation-manager +sudo systemctl start formation-manager +``` + +### 6.4 Vérifier le statut + +```bash +sudo systemctl status formation-manager +``` + +### 6.5 Voir les logs + +```bash +sudo journalctl -u formation-manager -f +``` + +--- + +## Étape 7 : Configuration de Nginx (optionnel mais recommandé) + +### 7.1 Installer Nginx + +```bash +sudo apt install -y nginx +``` + +### 7.2 Créer la configuration du site + +```bash +sudo nano /etc/nginx/sites-available/formation-manager +``` + +**Contenu du fichier :** + +```nginx +server { + listen 80; + server_name votre-domaine.com; # Changez par votre domaine + + # Logs + access_log /var/log/nginx/formation-manager-access.log; + error_log /var/log/nginx/formation-manager-error.log; + + # Proxy vers l'application Node.js + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} +``` + +### 7.3 Activer le site + +```bash +sudo ln -s /etc/nginx/sites-available/formation-manager /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl restart nginx +``` + +### 7.4 Configurer le pare-feu + +```bash +sudo ufw allow 'Nginx Full' +sudo ufw allow OpenSSH +sudo ufw enable +``` + +--- + +## Étape 8 : Configuration HTTPS avec Let's Encrypt (recommandé) + +### 8.1 Installer Certbot + +```bash +sudo apt install -y certbot python3-certbot-nginx +``` + +### 8.2 Obtenir un certificat SSL + +```bash +sudo certbot --nginx -d votre-domaine.com +``` + +Suivez les instructions à l'écran. + +### 8.3 Renouvellement automatique + +```bash +sudo systemctl status certbot.timer +``` + +Le renouvellement est automatique. + +--- + +## Étape 9 : Créer le premier utilisateur administrateur + +### 9.1 Se connecter à MySQL + +```bash +mysql -u formation_user -p formation_manager +``` + +### 9.2 Créer un utilisateur admin + +```sql +-- Générer un hash bcrypt pour le mot de passe "admin123" (à changer immédiatement) +-- Hash bcrypt de "admin123" : $2b$10$rKZhVJQX7mEWQxGqF8vQZOQxqxqxqxqxqxqxqxqxqxqxqxqxqxqxq + +INSERT INTO users (email, password, name, role, emailVerified, isActive, createdAt, updatedAt, lastSignedIn) +VALUES ( + 'admin@itinova.org', + '$2b$10$N9qo8uLOickgx2ZMRZoMye7FRNv8va91kpMQH6.OfzrcBizxDbvSK', -- Mot de passe: admin123 + 'Administrateur', + 'admin', + TRUE, + TRUE, + NOW(), + NOW(), + NOW() +); + +EXIT; +``` + +**⚠️ IMPORTANT** : Changez le mot de passe immédiatement après la première connexion ! + +--- + +## Étape 10 : Vérification et tests + +### 10.1 Accéder à l'application + +Ouvrez votre navigateur et accédez à : +- **HTTP** : `http://votre-domaine.com` +- **HTTPS** : `https://votre-domaine.com` + +### 10.2 Se connecter + +- **Email** : `admin@itinova.org` +- **Mot de passe** : `admin123` + +### 10.3 Changer le mot de passe + +1. Connectez-vous +2. Allez dans "Utilisateurs" +3. Modifiez votre profil +4. Changez le mot de passe + +--- + +## Maintenance + +### Voir les logs + +```bash +sudo journalctl -u formation-manager -f +``` + +### Redémarrer l'application + +```bash +sudo systemctl restart formation-manager +``` + +### Mettre à jour l'application + +```bash +cd /var/www/formation-manager-itinova +git pull # Si vous utilisez Git +pnpm install +pnpm build +sudo systemctl restart formation-manager +``` + +### Sauvegarder la base de données + +```bash +mysqldump -u formation_user -p formation_manager > backup_$(date +%Y%m%d_%H%M%S).sql +``` + +### Restaurer la base de données + +```bash +mysql -u formation_user -p formation_manager < backup_YYYYMMDD_HHMMSS.sql +``` + +--- + +## Dépannage + +### L'application ne démarre pas + +1. Vérifier les logs : + ```bash + sudo journalctl -u formation-manager -n 100 --no-pager + ``` + +2. Vérifier que le fichier .env existe et est correct : + ```bash + cat /var/www/formation-manager-itinova/.env + ``` + +3. Vérifier que la base de données est accessible : + ```bash + mysql -u formation_user -p formation_manager + ``` + +### Erreur de permissions + +```bash +sudo chown -R www-data:www-data /var/www/formation-manager-itinova +sudo mkdir -p /var/www/.local/share/pnpm +sudo chown -R www-data:www-data /var/www/.local +``` + +### Erreur "drizzle-kit: not found" + +```bash +cd /var/www/formation-manager-itinova +pnpm install +``` + +### Port 3000 déjà utilisé + +Modifiez le port dans `.env` : +```env +PORT=3001 +``` + +Puis redémarrez : +```bash +sudo systemctl restart formation-manager +``` + +--- + +## Sécurité + +### Recommandations + +1. **Changez immédiatement le mot de passe admin par défaut** +2. **Utilisez HTTPS** (Let's Encrypt gratuit) +3. **Configurez un pare-feu** (ufw) +4. **Mettez à jour régulièrement** le système et l'application +5. **Sauvegardez régulièrement** la base de données +6. **Limitez l'accès SSH** (clés SSH, désactiver root login) +7. **Surveillez les logs** régulièrement + +### Sauvegardes automatiques + +Créez un script de sauvegarde : + +```bash +sudo nano /usr/local/bin/backup-formation-manager.sh +``` + +Contenu : + +```bash +#!/bin/bash +BACKUP_DIR="/var/backups/formation-manager" +DATE=$(date +%Y%m%d_%H%M%S) + +mkdir -p $BACKUP_DIR + +# Sauvegarder la base de données +mysqldump -u formation_user -p'VOTRE_MOT_DE_PASSE' formation_manager > $BACKUP_DIR/db_$DATE.sql + +# Compresser +gzip $BACKUP_DIR/db_$DATE.sql + +# Garder seulement les 30 derniers jours +find $BACKUP_DIR -name "db_*.sql.gz" -mtime +30 -delete + +echo "Sauvegarde terminée : $BACKUP_DIR/db_$DATE.sql.gz" +``` + +Rendre exécutable : + +```bash +sudo chmod +x /usr/local/bin/backup-formation-manager.sh +``` + +Ajouter au cron (tous les jours à 2h du matin) : + +```bash +sudo crontab -e +``` + +Ajouter : + +``` +0 2 * * * /usr/local/bin/backup-formation-manager.sh >> /var/log/backup-formation-manager.log 2>&1 +``` + +--- + +## Support + +Pour toute question ou problème : + +1. Consultez les logs : `sudo journalctl -u formation-manager -f` +2. Vérifiez la documentation : `/var/www/formation-manager-itinova/README.md` +3. Contactez le support technique + +--- + +## Annexe : Commandes utiles + +```bash +# Statut du service +sudo systemctl status formation-manager + +# Démarrer le service +sudo systemctl start formation-manager + +# Arrêter le service +sudo systemctl stop formation-manager + +# Redémarrer le service +sudo systemctl restart formation-manager + +# Voir les logs en temps réel +sudo journalctl -u formation-manager -f + +# Voir les 100 dernières lignes de logs +sudo journalctl -u formation-manager -n 100 --no-pager + +# Tester la connexion à la base de données +mysql -u formation_user -p formation_manager + +# Vérifier la configuration Nginx +sudo nginx -t + +# Recharger Nginx +sudo systemctl reload nginx + +# Vérifier l'utilisation des ressources +htop + +# Vérifier l'espace disque +df -h + +# Vérifier les processus Node.js +ps aux | grep node +``` + +--- + +**Fin du guide de déploiement** diff --git a/MIGRATION_AUTH_LOCALE.md b/MIGRATION_AUTH_LOCALE.md new file mode 100644 index 0000000..5207ecf --- /dev/null +++ b/MIGRATION_AUTH_LOCALE.md @@ -0,0 +1,260 @@ +# Migration OAuth Manus vers Authentification Locale + +## État de la migration + +**Date** : 18 novembre 2024 +**Statut** : ⚠️ **En cours** (80% complété) + +--- + +## ✅ Modifications complétées + +### 1. Base de données + +- ✅ Schéma `users` modifié : + - Ajout du champ `email` (unique, not null) + - Ajout du champ `password` (hash bcrypt, not null) + - Ajout du champ `emailVerified` (boolean, default false) + - Suppression du champ `openId` + - Suppression du champ `loginMethod` + +### 2. Backend + +- ✅ Installation de `bcrypt` pour le hashage des mots de passe +- ✅ Création des helpers d'authentification (`server/_core/auth.ts`) : + - `hashPassword()` - Hashe un mot de passe avec bcrypt + - `verifyPassword()` - Vérifie un mot de passe + - `generateToken()` - Génère un token JWT + - `verifyToken()` - Vérifie un token JWT + +- ✅ Modification du fichier `server/db.ts` : + - `createUser()` - Crée un utilisateur avec email/password + - `getUserByEmail()` - Récupère un utilisateur par email + - `getUserById()` - Récupère un utilisateur par ID + - `updateUserLastSignedIn()` - Met à jour le dernier login + +- ✅ Modification du fichier `server/routers.ts` : + - Procédure `auth.register` - Inscription avec email/password + - Procédure `auth.login` - Connexion avec email/password + - Procédure `auth.logout` - Déconnexion (inchangée) + +- ✅ Modification du fichier `server/_core/context.ts` : + - Remplacement de l'authentification OAuth par JWT + - Lecture du token depuis le cookie + - Vérification et décodage du token + - Récupération de l'utilisateur depuis la base de données + +### 3. Frontend + +- ✅ Création de la page `Login.tsx` : + - Formulaire de connexion (email + mot de passe) + - Validation des champs + - Gestion des erreurs + - Redirection après connexion + - Lien vers la page d'inscription + +- ✅ Création de la page `Register.tsx` : + - Formulaire d'inscription (nom, email, mot de passe, confirmation) + - Validation des champs (minimum 6 caractères) + - Vérification de la correspondance des mots de passe + - Gestion des erreurs + - Redirection après inscription + - Lien vers la page de connexion + +- ✅ Modification du fichier `App.tsx` : + - Ajout des routes `/login` et `/register` + +- ✅ Modification du fichier `const.ts` : + - Remplacement de `getLoginUrl()` pour pointer vers `/login` + +### 4. Documentation + +- ✅ Création du guide de déploiement Linux complet (`GUIDE_DEPLOIEMENT_LINUX.md`) +- ✅ Création de ce document de migration + +--- + +## ⚠️ Modifications restantes + +### 1. Correction des erreurs + +- ❌ **Corriger les doublons dans `server/db.ts`** + - Il reste des fonctions dupliquées qui causent des erreurs de compilation + - Nettoyer complètement les anciennes fonctions OAuth + +### 2. Page de gestion des utilisateurs + +- ❌ **Modifier `AdminUsers.tsx`** : + - Remplacer le champ `openId` par `password` dans le formulaire de création + - Ajouter un champ de changement de mot de passe dans le formulaire d'édition + - Adapter les mutations pour utiliser email au lieu d'openId + +### 3. Hooks d'authentification + +- ❌ **Vérifier `useAuth()`** : + - S'assurer qu'il fonctionne avec la nouvelle authentification JWT + - Pas de modification normalement nécessaire car il utilise `trpc.auth.me` + +### 4. Suppression du code OAuth + +- ❌ **Supprimer les fichiers OAuth inutilisés** : + - `server/_core/sdk.ts` (si existe) + - `server/_core/oauth.ts` (si existe) + - Routes OAuth dans `server/_core/index.ts` + +### 5. Variables d'environnement + +- ❌ **Nettoyer le fichier `.env`** : + - Supprimer `OAUTH_SERVER_URL` + - Supprimer `VITE_OAUTH_PORTAL_URL` + - Supprimer `VITE_APP_ID` + - Supprimer `OWNER_OPEN_ID` + - Garder uniquement : + - `DATABASE_URL` + - `JWT_SECRET` + - `VITE_APP_TITLE` + - `VITE_APP_LOGO` + - Variables email (optionnelles) + +--- + +## 🚀 Prochaines étapes pour finaliser + +### Étape 1 : Corriger les erreurs de compilation + +```bash +cd /home/ubuntu/formation-manager-itinova + +# Vérifier les erreurs +pnpm check +``` + +Corriger manuellement les doublons dans `server/db.ts`. + +### Étape 2 : Modifier la page AdminUsers + +1. Ouvrir `client/src/pages/AdminUsers.tsx` +2. Remplacer le champ `openId` par `password` dans le formulaire de création +3. Ajouter un champ de changement de mot de passe (optionnel) dans l'édition + +### Étape 3 : Tester localement + +```bash +# Compiler +pnpm build + +# Démarrer +pnpm start +``` + +Tester : +1. Inscription d'un nouvel utilisateur +2. Connexion avec cet utilisateur +3. Déconnexion +4. Gestion des utilisateurs (admin) + +### Étape 4 : Créer le premier utilisateur admin + +Après déploiement, exécuter ce SQL : + +```sql +INSERT INTO users (email, password, name, role, emailVerified, isActive, createdAt, updatedAt, lastSignedIn) +VALUES ( + 'admin@itinova.org', + '$2b$10$N9qo8uLOickgx2ZMRZoMye7FRNv8va91kpMQH6.OfzrcBizxDbvSK', -- Mot de passe: admin123 + 'Administrateur', + 'admin', + TRUE, + TRUE, + NOW(), + NOW(), + NOW() +); +``` + +⚠️ **Changez immédiatement le mot de passe après la première connexion !** + +### Étape 5 : Déployer sur le serveur + +Suivre le guide `GUIDE_DEPLOIEMENT_LINUX.md`. + +--- + +## 📋 Checklist de déploiement + +### Avant le déploiement + +- [ ] Toutes les erreurs de compilation sont corrigées +- [ ] Les tests locaux passent (inscription, connexion, déconnexion) +- [ ] Le fichier `.env` est configuré correctement +- [ ] La base de données est accessible +- [ ] Un utilisateur admin est créé en base de données + +### Pendant le déploiement + +- [ ] Les fichiers sont copiés sur le serveur +- [ ] Les dépendances sont installées (`pnpm install`) +- [ ] La base de données est initialisée (`pnpm db:push`) +- [ ] L'application est compilée (`pnpm build`) +- [ ] Le service systemd est configuré +- [ ] Le service démarre sans erreur + +### Après le déploiement + +- [ ] L'application est accessible via le navigateur +- [ ] La connexion fonctionne +- [ ] L'inscription fonctionne +- [ ] Le tableau de bord admin est accessible +- [ ] Les fonctionnalités de gestion des utilisateurs fonctionnent +- [ ] Le mot de passe admin par défaut a été changé + +--- + +## 🔧 Dépannage + +### Erreur "Email ou mot de passe incorrect" + +- Vérifier que l'utilisateur existe en base de données +- Vérifier que le mot de passe est bien hashé avec bcrypt +- Vérifier les logs du serveur + +### Erreur "User is not defined" + +- Vérifier que le token JWT est valide +- Vérifier que le cookie est bien envoyé +- Vérifier que `context.ts` récupère bien l'utilisateur + +### Erreur "Cannot read property 'id' of null" + +- L'utilisateur n'est pas connecté +- Rediriger vers `/login` + +### L'application ne démarre pas + +- Vérifier les logs : `sudo journalctl -u formation-manager -f` +- Vérifier le fichier `.env` +- Vérifier la connexion à la base de données + +--- + +## 📚 Ressources + +- Guide de déploiement : `GUIDE_DEPLOIEMENT_LINUX.md` +- Documentation bcrypt : https://www.npmjs.com/package/bcrypt +- Documentation JWT : https://jwt.io/ +- Documentation tRPC : https://trpc.io/ + +--- + +## 🆘 Support + +En cas de problème : + +1. Consulter les logs du serveur +2. Vérifier la base de données +3. Consulter ce document de migration +4. Consulter le guide de déploiement + +--- + +**Note** : Cette migration remplace complètement OAuth Manus par une authentification locale. L'application devient totalement autonome et ne dépend plus de services externes pour l'authentification. diff --git a/client/src/App.tsx b/client/src/App.tsx index eeb8c86..e9c58e5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -15,10 +15,14 @@ import AdminUsers from "./pages/AdminUsers"; import AdminEmailTemplates from "./pages/AdminEmailTemplates"; import AdminEmailConfig from "./pages/AdminEmailConfig"; import Inscription from "./pages/Inscription"; +import Login from "./pages/Login"; +import Register from "./pages/Register"; function Router() { return ( + + diff --git a/client/src/const.ts b/client/src/const.ts index 08f9c00..414e678 100644 --- a/client/src/const.ts +++ b/client/src/const.ts @@ -4,18 +4,5 @@ export const APP_TITLE = import.meta.env.VITE_APP_TITLE || "App"; export const APP_LOGO = "https://placehold.co/128x128/E1E7EF/1F2937?text=App"; -// Generate login URL at runtime so redirect URI reflects the current origin. -export const getLoginUrl = () => { - const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL; - const appId = import.meta.env.VITE_APP_ID; - const redirectUri = `${window.location.origin}/api/oauth/callback`; - const state = btoa(redirectUri); - - const url = new URL(`${oauthPortalUrl}/app-auth`); - url.searchParams.set("appId", appId); - url.searchParams.set("redirectUri", redirectUri); - url.searchParams.set("state", state); - url.searchParams.set("type", "signIn"); - - return url.toString(); -}; +// Retourne l'URL de la page de connexion locale +export const getLoginUrl = () => "/login"; diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx new file mode 100644 index 0000000..0ca3d42 --- /dev/null +++ b/client/src/pages/Login.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { useLocation } from "wouter"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { APP_LOGO, APP_TITLE } from "@/const"; +import { Loader2 } from "lucide-react"; + +export default function Login() { + const [, setLocation] = useLocation(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + const loginMutation = trpc.auth.login.useMutation({ + onSuccess: () => { + // Rediriger vers le tableau de bord + window.location.href = "/"; + }, + onError: (error) => { + setError(error.message); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (!email || !password) { + setError("Veuillez remplir tous les champs"); + return; + } + + loginMutation.mutate({ email, password }); + }; + + return ( +
+ + + {APP_LOGO && ( +
+ {APP_TITLE} +
+ )} + {APP_TITLE} + + Connectez-vous à votre compte + +
+
+ + {error && ( + + {error} + + )} + +
+ + setEmail(e.target.value)} + disabled={loginMutation.isPending} + required + /> +
+ +
+ + setPassword(e.target.value)} + disabled={loginMutation.isPending} + required + /> +
+
+ + + + +
+ Pas encore de compte ?{" "} + +
+
+
+
+
+ ); +} diff --git a/client/src/pages/Register.tsx b/client/src/pages/Register.tsx new file mode 100644 index 0000000..63ee3a3 --- /dev/null +++ b/client/src/pages/Register.tsx @@ -0,0 +1,156 @@ +import { useState } from "react"; +import { useLocation } from "wouter"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { APP_LOGO, APP_TITLE } from "@/const"; +import { Loader2 } from "lucide-react"; + +export default function Register() { + const [, setLocation] = useLocation(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(""); + + const registerMutation = trpc.auth.register.useMutation({ + onSuccess: () => { + // Rediriger vers le tableau de bord + window.location.href = "/"; + }, + onError: (error) => { + setError(error.message); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (!email || !password || !confirmPassword) { + setError("Veuillez remplir tous les champs obligatoires"); + return; + } + + if (password.length < 6) { + setError("Le mot de passe doit contenir au moins 6 caractères"); + return; + } + + if (password !== confirmPassword) { + setError("Les mots de passe ne correspondent pas"); + return; + } + + registerMutation.mutate({ email, password, name: name || undefined }); + }; + + return ( +
+ + + {APP_LOGO && ( +
+ {APP_TITLE} +
+ )} + Créer un compte + + Inscrivez-vous pour accéder à {APP_TITLE} + +
+
+ + {error && ( + + {error} + + )} + +
+ + setName(e.target.value)} + disabled={registerMutation.isPending} + /> +
+ +
+ + setEmail(e.target.value)} + disabled={registerMutation.isPending} + required + /> +
+ +
+ + setPassword(e.target.value)} + disabled={registerMutation.isPending} + required + /> +

+ Minimum 6 caractères +

+
+ +
+ + setConfirmPassword(e.target.value)} + disabled={registerMutation.isPending} + required + /> +
+
+ + + + +
+ Déjà un compte ?{" "} + +
+
+
+
+
+ ); +} diff --git a/drizzle/0010_flat_clint_barton.sql b/drizzle/0010_flat_clint_barton.sql new file mode 100644 index 0000000..b1cb996 --- /dev/null +++ b/drizzle/0010_flat_clint_barton.sql @@ -0,0 +1,7 @@ +ALTER TABLE `users` DROP INDEX `users_openId_unique`;--> statement-breakpoint +ALTER TABLE `users` MODIFY COLUMN `email` varchar(320) NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD `password` varchar(255) NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD `emailVerified` boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD CONSTRAINT `users_email_unique` UNIQUE(`email`);--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `openId`;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `loginMethod`; \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..e04bd74 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,780 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "e34c8fec-5308-4ce7-b687-d27c3093036c", + "prevId": "a099e856-0b0c-430d-8160-ca950c994fe5", + "tables": { + "apprenants": { + "name": "apprenants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prenom": { + "name": "prenom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeEtablissement": { + "name": "codeEtablissement", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fonction": { + "name": "fonction", + "type": "enum('directeur','chef_service','autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "apprenants_id": { + "name": "apprenants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "apprenants_email_unique": { + "name": "apprenants_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "datesFormation": { + "name": "datesFormation", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateDebut": { + "name": "dateDebut", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateFin": { + "name": "dateFin", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ordre": { + "name": "ordre", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "datesFormation_id": { + "name": "datesFormation_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "emailConfig": { + "name": "emailConfig", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'resend'" + }, + "apiKey": { + "name": "apiKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fromEmail": { + "name": "fromEmail", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fromName": { + "name": "fromName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Formation Manager Itinova'" + }, + "mode": { + "name": "mode", + "type": "enum('simulation','production')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'simulation'" + }, + "domainVerified": { + "name": "domainVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "emailConfig_id": { + "name": "emailConfig_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "emailTemplates": { + "name": "emailTemplates", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primaryColor": { + "name": "primaryColor", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#2563eb'" + }, + "headerBgColor": { + "name": "headerBgColor", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#2563eb'" + }, + "headerTextColor": { + "name": "headerTextColor", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#ffffff'" + }, + "headerTitle": { + "name": "headerTitle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Formation Manager Itinova'" + }, + "footerText": { + "name": "footerText", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "emailTemplates_id": { + "name": "emailTemplates_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "emailTemplates_type_unique": { + "name": "emailTemplates_type_unique", + "columns": [ + "type" + ] + } + }, + "checkConstraint": {} + }, + "formations": { + "name": "formations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lienUnique": { + "name": "lienUnique", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "formations_id": { + "name": "formations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "formations_lienUnique_unique": { + "name": "formations_lienUnique_unique", + "columns": [ + "lienUnique" + ] + } + }, + "checkConstraint": {} + }, + "inscriptions": { + "name": "inscriptions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "apprenantId": { + "name": "apprenantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('confirmee','liste_attente','annulee')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateInscription": { + "name": "dateInscription", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inscriptions_id": { + "name": "inscriptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "passwordResetTokens": { + "name": "passwordResetTokens", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "passwordResetTokens_id": { + "name": "passwordResetTokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "passwordResetTokens_token_unique": { + "name": "passwordResetTokens_token_unique", + "columns": [ + "token" + ] + } + }, + "checkConstraint": {} + }, + "sequences": { + "name": "sequences", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "formationId": { + "name": "formationId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lieu": { + "name": "lieu", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publicCible": { + "name": "publicCible", + "type": "enum('directeur','chef_service','autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capaciteMax": { + "name": "capaciteMax", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 12 + }, + "dateBlocage": { + "name": "dateBlocage", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('ouverte','bloquee','terminee')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ouverte'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sequences_id": { + "name": "sequences_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5dc40cf..37d6a79 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1763448844826, "tag": "0009_polite_senator_kelly", "breakpoints": true + }, + { + "idx": 10, + "version": "5", + "when": 1763544545719, + "tag": "0010_flat_clint_barton", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index c68adc0..1acdefc 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -11,11 +11,13 @@ export const users = mysqlTable("users", { * Use this for relations between tables. */ id: int("id").autoincrement().primaryKey(), - /** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */ - openId: varchar("openId", { length: 64 }).notNull().unique(), + /** Email unique pour la connexion */ + email: varchar("email", { length: 320 }).notNull().unique(), + /** Mot de passe hashé avec bcrypt */ + password: varchar("password", { length: 255 }).notNull(), name: text("name"), - email: varchar("email", { length: 320 }), - loginMethod: varchar("loginMethod", { length: 64 }), + /** Email vérifié ou non */ + emailVerified: boolean("emailVerified").default(false).notNull(), role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(), /** Statut du compte (actif/inactif) */ isActive: boolean("isActive").default(true).notNull(), diff --git a/package.json b/package.json index e82644a..2b15e0f 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@trpc/react-query": "^11.6.0", "@trpc/server": "^11.6.0", "axios": "^1.12.0", + "bcrypt": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -85,6 +86,7 @@ "@builder.io/vite-plugin-jsx-loc": "^0.1.1", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.1.3", + "@types/bcrypt": "^6.0.0", "@types/express": "4.17.21", "@types/google.maps": "^3.58.1", "@types/node": "^24.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0308415..c521f97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: axios: specifier: ^1.12.0 version: 1.12.2 + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -227,6 +230,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.3 version: 4.1.14(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)) + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 '@types/express': specifier: 4.17.21 version: 4.17.21 @@ -2157,6 +2163,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -2443,6 +2452,10 @@ packages: resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==} hasBin: true + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -3702,6 +3715,10 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + node-addon-api@8.5.0: + resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} + engines: {node: ^18 || ^20 || >= 21} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -3716,6 +3733,10 @@ packages: encoding: optional: true + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.23: resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} @@ -6641,6 +6662,10 @@ snapshots: dependencies: '@babel/types': 7.28.4 + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 24.7.0 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -6973,6 +6998,11 @@ snapshots: baseline-browser-mapping@2.8.12: {} + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -8496,12 +8526,16 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + node-addon-api@8.5.0: {} + node-domexception@1.0.0: {} node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-gyp-build@4.8.4: {} + node-releases@2.0.23: {} normalize-range@0.1.2: {} diff --git a/server/_core/auth.ts b/server/_core/auth.ts new file mode 100644 index 0000000..3ad8244 --- /dev/null +++ b/server/_core/auth.ts @@ -0,0 +1,43 @@ +import bcrypt from "bcrypt"; +import { sign, verify } from "jsonwebtoken"; +import { ENV } from "./env"; + +const SALT_ROUNDS = 10; + +/** + * Hashe un mot de passe avec bcrypt + */ +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +/** + * Vérifie un mot de passe contre son hash + */ +export async function verifyPassword( + password: string, + hash: string +): Promise { + return bcrypt.compare(password, hash); +} + +/** + * Génère un token JWT pour un utilisateur + */ +export function generateToken(userId: number): string { + return sign({ userId }, ENV.jwtSecret, { + expiresIn: "30d", + }); +} + +/** + * Vérifie et décode un token JWT + */ +export function verifyToken(token: string): { userId: number } | null { + try { + const decoded = verify(token, ENV.jwtSecret) as { userId: number }; + return decoded; + } catch { + return null; + } +} diff --git a/server/_core/context.ts b/server/_core/context.ts index e4ae108..be6955f 100644 --- a/server/_core/context.ts +++ b/server/_core/context.ts @@ -1,6 +1,8 @@ import type { CreateExpressContextOptions } from "@trpc/server/adapters/express"; import type { User } from "../../drizzle/schema"; -import { sdk } from "./sdk"; +import { verifyToken } from "./auth"; +import { getUserById } from "../db"; +import { COOKIE_NAME } from "@shared/const"; export type TrpcContext = { req: CreateExpressContextOptions["req"]; @@ -14,7 +16,18 @@ export async function createContext( let user: User | null = null; try { - user = await sdk.authenticateRequest(opts.req); + // Récupérer le token JWT depuis le cookie + const token = opts.req.cookies[COOKIE_NAME]; + + if (token) { + // Vérifier et décoder le token + const decoded = verifyToken(token); + + if (decoded) { + // Récupérer l'utilisateur depuis la base de données + user = await getUserById(decoded.userId) || null; + } + } } catch (error) { // Authentication is optional for public procedures. user = null; diff --git a/server/db.ts b/server/db.ts index b51bf4d..0c3466c 100644 --- a/server/db.ts +++ b/server/db.ts @@ -42,77 +42,72 @@ export async function getDb() { return _db; } -export async function upsertUser(user: InsertUser): Promise { - if (!user.openId) { - throw new Error("User openId is required for upsert"); +/** + * Crée un nouvel utilisateur avec email/mot de passe + */ +export async function createUser(user: InsertUser): Promise { + if (!user.email) { + throw new Error("User email is required"); + } + if (!user.password) { + throw new Error("User password is required"); } const db = await getDb(); if (!db) { - console.warn("[Database] Cannot upsert user: database not available"); - return; + throw new Error("Database not available"); } try { - const values: InsertUser = { - openId: user.openId, - }; - const updateSet: Record = {}; - - const textFields = ["name", "email", "loginMethod"] as const; - type TextField = (typeof textFields)[number]; - - const assignNullable = (field: TextField) => { - const value = user[field]; - if (value === undefined) return; - const normalized = value ?? null; - values[field] = normalized; - updateSet[field] = normalized; - }; - - textFields.forEach(assignNullable); - - if (user.lastSignedIn !== undefined) { - values.lastSignedIn = user.lastSignedIn; - updateSet.lastSignedIn = user.lastSignedIn; - } - if (user.role !== undefined) { - values.role = user.role; - updateSet.role = user.role; - } else if (user.openId === ENV.ownerOpenId) { - values.role = 'admin'; - updateSet.role = 'admin'; - } - - if (!values.lastSignedIn) { - values.lastSignedIn = new Date(); - } - - if (Object.keys(updateSet).length === 0) { - updateSet.lastSignedIn = new Date(); - } - - await db.insert(users).values(values).onDuplicateKeyUpdate({ - set: updateSet, - }); + const result = await db.insert(users).values(user); + return Number(result[0].insertId); } catch (error) { - console.error("[Database] Failed to upsert user:", error); + console.error("[Database] Failed to create user:", error); throw error; } } -export async function getUserByOpenId(openId: string) { +/** + * Récupère un utilisateur par son email + */ +export async function getUserByEmail(email: string) { const db = await getDb(); if (!db) { console.warn("[Database] Cannot get user: database not available"); return undefined; } - const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1); - + const result = await db.select().from(users).where(eq(users.email, email)).limit(1); return result.length > 0 ? result[0] : undefined; } +/** + * Récupère un utilisateur par son ID + */ +export async function getUserById(id: number) { + const db = await getDb(); + if (!db) { + console.warn("[Database] Cannot get user: database not available"); + return undefined; + } + + const result = await db.select().from(users).where(eq(users.id, id)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +/** + * Met à jour le dernier login d'un utilisateur + */ +export async function updateUserLastSignedIn(userId: number): Promise { + const db = await getDb(); + if (!db) { + console.warn("[Database] Cannot update user: database not available"); + return; + } + + await db.update(users).set({ lastSignedIn: new Date() }).where(eq(users.id, userId)); +} + // ==================== FORMATIONS ==================== export async function createFormation(data: InsertFormation) { @@ -383,14 +378,6 @@ export async function deleteInscription(id: number) { // ==================== GESTION DES UTILISATEURS ==================== -export async function createUser(data: InsertUser) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - const result = await db.insert(users).values(data); - return result; -} - export async function getAllUsers() { const db = await getDb(); if (!db) return []; @@ -398,14 +385,6 @@ export async function getAllUsers() { return await db.select().from(users); } -export async function getUserById(id: number) { - const db = await getDb(); - if (!db) return undefined; - - const result = await db.select().from(users).where(eq(users.id, id)).limit(1); - return result.length > 0 ? result[0] : undefined; -} - export async function updateUser(id: number, data: Partial) { const db = await getDb(); if (!db) throw new Error("Database not available"); diff --git a/server/routers.ts b/server/routers.ts index c70a996..774f4a6 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -21,6 +21,83 @@ export const appRouter = router({ system: systemRouter, auth: router({ me: publicProcedure.query(opts => opts.ctx.user), + + register: publicProcedure + .input(z.object({ + email: z.string().email(), + password: z.string().min(6), + name: z.string().optional(), + })) + .mutation(async ({ input, ctx }) => { + const { hashPassword, generateToken } = await import("./_core/auth"); + + // Vérifier si l'utilisateur existe déjà + const existingUser = await db.getUserByEmail(input.email); + if (existingUser) { + throw new TRPCError({ code: 'CONFLICT', message: 'Un compte existe déjà avec cet email' }); + } + + // Hasher le mot de passe + const hashedPassword = await hashPassword(input.password); + + // Créer l'utilisateur + const userId = await db.createUser({ + email: input.email, + password: hashedPassword, + name: input.name, + emailVerified: false, + role: 'user', + isActive: true, + }); + + // Générer un token JWT + const token = generateToken(userId); + + // Définir le cookie de session + const cookieOptions = getSessionCookieOptions(ctx.req); + ctx.res.cookie(COOKIE_NAME, token, cookieOptions); + + return { success: true, userId }; + }), + + login: publicProcedure + .input(z.object({ + email: z.string().email(), + password: z.string(), + })) + .mutation(async ({ input, ctx }) => { + const { verifyPassword, generateToken } = await import("./_core/auth"); + + // Récupérer l'utilisateur + const user = await db.getUserByEmail(input.email); + if (!user) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Email ou mot de passe incorrect' }); + } + + // Vérifier le compte actif + if (!user.isActive) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Votre compte a été désactivé' }); + } + + // Vérifier le mot de passe + const isValid = await verifyPassword(input.password, user.password); + if (!isValid) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Email ou mot de passe incorrect' }); + } + + // Mettre à jour le dernier login + await db.updateUserLastSignedIn(user.id); + + // Générer un token JWT + const token = generateToken(user.id); + + // Définir le cookie de session + const cookieOptions = getSessionCookieOptions(ctx.req); + ctx.res.cookie(COOKIE_NAME, token, cookieOptions); + + return { success: true, user: { id: user.id, email: user.email, name: user.name, role: user.role } }; + }), + logout: publicProcedure.mutation(({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 }); diff --git a/todo.md b/todo.md index cbb9951..a691b73 100644 --- a/todo.md +++ b/todo.md @@ -241,3 +241,17 @@ - [x] Ajouter le basculement simulation/production - [x] Ajouter le lien dans le menu de navigation - [x] Tester la configuration et l'envoi d'emails + +## Remplacement OAuth Manus par authentification locale + +- [x] Modifier le schéma de la table users (ajouter password, emailVerified, supprimer openId) +- [x] Installer bcrypt pour le hashage des mots de passe +- [x] Créer les helpers d'authentification (hashPassword, verifyPassword) +- [x] Modifier les procédures auth (login, register, logout) +- [x] Modifier le système de context pour utiliser JWT +- [x] Créer la page de connexion +- [x] Créer la page d'inscription +- [ ] Modifier la page de gestion des utilisateurs (ajouter mot de passe) +- [ ] Mettre à jour useAuth pour utiliser la nouvelle authentification +- [ ] Tester la connexion et l'inscription +- [ ] Créer un guide de déploiement sans OAuth