diff --git a/backend/src/main.ts b/backend/src/main.ts index 1602cec..3943463 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -11,7 +11,14 @@ import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log', 'debug', 'verbose'] }); - app.enableCors(); + + // Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production + app.enableCors({ + origin: true, // Autorise toutes les origines (dev) - à restreindre en prod + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Accept'], + credentials: true, + }); app.useGlobalPipes( new ValidationPipe({ diff --git a/frontend/lib/config/env.dart b/frontend/lib/config/env.dart index e044e1a..34e1cbd 100644 --- a/frontend/lib/config/env.dart +++ b/frontend/lib/config/env.dart @@ -2,7 +2,7 @@ class Env { // Base URL de l'API, surchargeable à la compilation via --dart-define=API_BASE_URL static const String apiBaseUrl = String.fromEnvironment( 'API_BASE_URL', - defaultValue: 'https://ynov.ptits-pas.fr', + defaultValue: 'https://app.ptits-pas.fr', ); // Construit une URL vers l'API v1 à partir d'un chemin (commençant par '/') diff --git a/frontend/lib/models/user.dart b/frontend/lib/models/user.dart index 7511eb2..8091919 100644 --- a/frontend/lib/models/user.dart +++ b/frontend/lib/models/user.dart @@ -4,6 +4,7 @@ class AppUser { final String role; final DateTime createdAt; final DateTime updatedAt; + final bool changementMdpObligatoire; AppUser({ required this.id, @@ -11,6 +12,7 @@ class AppUser { required this.role, required this.createdAt, required this.updatedAt, + this.changementMdpObligatoire = false, }); factory AppUser.fromJson(Map json) { @@ -20,6 +22,7 @@ class AppUser { role: json['role'] as String, createdAt: DateTime.parse(json['createdAt'] as String), updatedAt: DateTime.parse(json['updatedAt'] as String), + changementMdpObligatoire: json['changement_mdp_obligatoire'] as bool? ?? false, ); } @@ -30,6 +33,7 @@ class AppUser { 'role': role, 'createdAt': createdAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(), + 'changement_mdp_obligatoire': changementMdpObligatoire, }; } } \ No newline at end of file diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index 6f3cc9c..23d7af1 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -4,9 +4,10 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:url_launcher/url_launcher.dart'; import 'package:p_tits_pas/services/bug_report_service.dart'; -import 'package:go_router/go_router.dart'; import '../../widgets/image_button.dart'; import '../../widgets/custom_app_text_field.dart'; +import '../../services/auth_service.dart'; +import '../../widgets/auth/change_password_dialog.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -19,6 +20,9 @@ class _LoginPageState extends State { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); + + bool _isLoading = false; + String? _errorMessage; @override void dispose() { @@ -41,12 +45,80 @@ class _LoginPageState extends State { if (value == null || value.isEmpty) { return 'Veuillez entrer votre mot de passe'; } - if (value.length < 6) { - return 'Le mot de passe doit contenir au moins 6 caractères'; - } return null; } + /// Gère la connexion de l'utilisateur + Future _handleLogin() async { + // Réinitialiser le message d'erreur + setState(() { + _errorMessage = null; + }); + + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } + + setState(() { + _isLoading = true; + }); + + try { + // Appeler le service d'authentification + final user = await AuthService.login( + _emailController.text.trim(), + _passwordController.text, + ); + + if (!mounted) return; + + // Vérifier si l'utilisateur doit changer son mot de passe + if (user.changementMdpObligatoire) { + if (!mounted) return; + + // Afficher la modale de changement de mot de passe (non-dismissible) + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const ChangePasswordDialog(), + ); + + // Si le changement de mot de passe a réussi, rafraîchir l'utilisateur + if (result == true && mounted) { + await AuthService.refreshCurrentUser(); + } + } + + if (!mounted) return; + + // Rediriger selon le rôle de l'utilisateur + _redirectUserByRole(user.role); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } + + /// Redirige l'utilisateur selon son rôle + void _redirectUserByRole(String role) { + switch (role.toLowerCase()) { + case 'super_admin': + case 'gestionnaire': + Navigator.pushReplacementNamed(context, '/admin-dashboard'); + break; + case 'parent': + Navigator.pushReplacementNamed(context, '/parent-dashboard'); + break; + case 'assistante_maternelle': + Navigator.pushReplacementNamed(context, '/am-dashboard'); + break; + default: + Navigator.pushReplacementNamed(context, '/home'); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -136,20 +208,46 @@ class _LoginPageState extends State { ], ), const SizedBox(height: 20), + + // Message d'erreur + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 15), + decoration: BoxDecoration( + color: Colors.red[50], + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.red[300]!), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: Colors.red[700], size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + _errorMessage!, + style: GoogleFonts.merienda( + fontSize: 12, + color: Colors.red[700], + ), + ), + ), + ], + ), + ), + // Bouton centré Center( - child: ImageButton( - bg: 'assets/images/btn_green.png', - width: 300, - height: 40, - text: 'Se connecter', - textColor: const Color(0xFF2D6A4F), - onPressed: () { - if (_formKey.currentState?.validate() ?? false) { - // TODO: Implémenter la logique de connexion - } - }, - ), + child: _isLoading + ? const CircularProgressIndicator() + : ImageButton( + bg: 'assets/images/btn_green.png', + width: 300, + height: 40, + text: 'Se connecter', + textColor: const Color(0xFF2D6A4F), + onPressed: _handleLogin, + ), ), const SizedBox(height: 10), // Lien mot de passe oublié diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 700673e..bd157f8 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -1,11 +1,13 @@ class ApiConfig { // static const String baseUrl = 'http://localhost:3000/api/v1/'; - static const String baseUrl = 'https://ynov.ptits-pas.fr/api/v1'; + static const String baseUrl = 'https://app.ptits-pas.fr/api/v1'; // Auth endpoints static const String login = '/auth/login'; static const String register = '/auth/register'; static const String refreshToken = '/auth/refresh'; + static const String authMe = '/auth/me'; + static const String changePasswordRequired = '/auth/change-password-required'; // Users endpoints static const String users = '/users'; diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 5234c8e..0acaefb 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -1,42 +1,146 @@ import 'dart:convert'; +import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../models/user.dart'; +import 'api/api_config.dart'; +import 'api/tokenService.dart'; class AuthService { - static const String _usersKey = 'users'; - static const String _parentsKey = 'parents'; - static const String _childrenKey = 'children'; + static const String _currentUserKey = 'current_user'; - // Méthode pour se connecter (mode démonstration) + /// Connexion de l'utilisateur + /// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire static Future login(String email, String password) async { - await Future.delayed(const Duration(seconds: 1)); // Simule un délai de traitement - throw Exception('Mode démonstration - Connexion désactivée'); + try { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.login}'), + headers: ApiConfig.headers, + body: jsonEncode({ + 'email': email, + 'password': password, + }), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + final data = jsonDecode(response.body); + + // Stocker les tokens + await TokenService.saveToken(data['accessToken']); + await TokenService.saveRefreshToken(data['refreshToken']); + + // Récupérer le profil utilisateur pour avoir toutes les infos + final user = await _fetchUserProfile(data['accessToken']); + + // Stocker l'utilisateur en cache + await _saveCurrentUser(user); + + return user; + } else { + final error = jsonDecode(response.body); + throw Exception(error['message'] ?? 'Erreur de connexion'); + } + } catch (e) { + if (e is Exception) rethrow; + throw Exception('Erreur réseau: impossible de se connecter au serveur'); + } } - // Méthode pour s'inscrire (mode démonstration) - static Future register({ - required String email, - required String password, - required String firstName, - required String lastName, - required String role, + /// Récupère le profil utilisateur via /auth/me + static Future _fetchUserProfile(String token) async { + try { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.authMe}'), + headers: ApiConfig.authHeaders(token), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return AppUser.fromJson(data); + } else { + throw Exception('Erreur lors de la récupération du profil'); + } + } catch (e) { + if (e is Exception) rethrow; + throw Exception('Erreur réseau: impossible de récupérer le profil'); + } + } + + /// Changement de mot de passe obligatoire + static Future changePasswordRequired({ + required String currentPassword, + required String newPassword, }) async { - await Future.delayed(const Duration(seconds: 1)); // Simule un délai de traitement - throw Exception('Mode démonstration - Inscription désactivée'); + final token = await TokenService.getToken(); + if (token == null) { + throw Exception('Non authentifié'); + } + + try { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.changePasswordRequired}'), + headers: ApiConfig.authHeaders(token), + body: jsonEncode({ + 'currentPassword': currentPassword, + 'newPassword': newPassword, + }), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + final error = jsonDecode(response.body); + throw Exception(error['message'] ?? 'Erreur lors du changement de mot de passe'); + } + + // Après le changement de MDP, rafraîchir le profil utilisateur + final user = await _fetchUserProfile(token); + await _saveCurrentUser(user); + } catch (e) { + if (e is Exception) rethrow; + throw Exception('Erreur réseau: impossible de changer le mot de passe'); + } } - // Méthode pour se déconnecter (mode démonstration) + /// Déconnexion de l'utilisateur static Future logout() async { - // Ne fait rien en mode démonstration + await TokenService.clearAll(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_currentUserKey); } - // Méthode pour vérifier si l'utilisateur est connecté (mode démonstration) + /// Vérifie si l'utilisateur est connecté static Future isLoggedIn() async { - return false; // Toujours non connecté en mode démonstration + final token = await TokenService.getToken(); + return token != null; } - // Méthode pour récupérer l'utilisateur connecté (mode démonstration) + /// Récupère l'utilisateur connecté depuis le cache static Future getCurrentUser() async { - return null; // Aucun utilisateur en mode démonstration + final prefs = await SharedPreferences.getInstance(); + final userJson = prefs.getString(_currentUserKey); + + if (userJson != null) { + return AppUser.fromJson(jsonDecode(userJson)); + } + + return null; + } + + /// Sauvegarde l'utilisateur actuel en cache + static Future _saveCurrentUser(AppUser user) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_currentUserKey, jsonEncode(user.toJson())); + } + + /// Rafraîchit le profil utilisateur depuis l'API + static Future refreshCurrentUser() async { + final token = await TokenService.getToken(); + if (token == null) return null; + + try { + final user = await _fetchUserProfile(token); + await _saveCurrentUser(user); + return user; + } catch (e) { + return null; + } } } \ No newline at end of file diff --git a/frontend/lib/widgets/auth/change_password_dialog.dart b/frontend/lib/widgets/auth/change_password_dialog.dart new file mode 100644 index 0000000..363ed71 --- /dev/null +++ b/frontend/lib/widgets/auth/change_password_dialog.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../services/auth_service.dart'; +import '../../widgets/custom_app_text_field.dart'; +import '../../widgets/image_button.dart'; + +/// Dialogue modal bloquant pour le changement de mot de passe obligatoire +/// Utilisé après la première connexion quand changement_mdp_obligatoire = true +class ChangePasswordDialog extends StatefulWidget { + const ChangePasswordDialog({super.key}); + + @override + State createState() => _ChangePasswordDialogState(); +} + +class _ChangePasswordDialogState extends State { + final _formKey = GlobalKey(); + final _currentPasswordController = TextEditingController(); + final _newPasswordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _currentPasswordController.dispose(); + _newPasswordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + String? _validateCurrentPassword(String? value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer votre mot de passe actuel'; + } + return null; + } + + String? _validateNewPassword(String? value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer un nouveau mot de passe'; + } + if (value.length < 8) { + return 'Le mot de passe doit contenir au moins 8 caractères'; + } + if (!RegExp(r'[A-Z]').hasMatch(value)) { + return 'Le mot de passe doit contenir au moins une majuscule'; + } + if (!RegExp(r'[a-z]').hasMatch(value)) { + return 'Le mot de passe doit contenir au moins une minuscule'; + } + if (!RegExp(r'[0-9]').hasMatch(value)) { + return 'Le mot de passe doit contenir au moins un chiffre'; + } + return null; + } + + String? _validateConfirmPassword(String? value) { + if (value == null || value.isEmpty) { + return 'Veuillez confirmer votre nouveau mot de passe'; + } + if (value != _newPasswordController.text) { + return 'Les mots de passe ne correspondent pas'; + } + return null; + } + + Future _handleSubmit() async { + // Réinitialiser le message d'erreur + setState(() { + _errorMessage = null; + }); + + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } + + setState(() { + _isLoading = true; + }); + + try { + await AuthService.changePasswordRequired( + currentPassword: _currentPasswordController.text, + newPassword: _newPasswordController.text, + ); + + if (mounted) { + // Fermer le dialogue avec succès + Navigator.of(context).pop(true); + + // Afficher un message de succès + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Mot de passe modifié avec succès', + style: GoogleFonts.merienda(), + ), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } + + @override + Widget build(BuildContext context) { + return PopScope( + // Empêcher la fermeture du dialogue avec le bouton retour + canPop: false, + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Container( + constraints: const BoxConstraints(maxWidth: 500), + padding: const EdgeInsets.all(30), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Titre + Text( + 'Changement de mot de passe obligatoire', + style: GoogleFonts.merienda( + fontSize: 20, + fontWeight: FontWeight.bold, + color: const Color(0xFF2D6A4F), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + + // Message d'explication + Text( + 'Pour des raisons de sécurité, vous devez changer votre mot de passe avant de continuer.', + style: GoogleFonts.merienda( + fontSize: 14, + color: Colors.black87, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 25), + + // Champ mot de passe actuel + CustomAppTextField( + controller: _currentPasswordController, + labelText: 'Mot de passe actuel', + hintText: 'Votre mot de passe actuel', + obscureText: true, + validator: _validateCurrentPassword, + style: CustomAppTextFieldStyle.lavande, + fieldHeight: 53, + fieldWidth: double.infinity, + enabled: !_isLoading, + ), + const SizedBox(height: 15), + + // Champ nouveau mot de passe + CustomAppTextField( + controller: _newPasswordController, + labelText: 'Nouveau mot de passe', + hintText: 'Minimum 8 caractères', + obscureText: true, + validator: _validateNewPassword, + style: CustomAppTextFieldStyle.jaune, + fieldHeight: 53, + fieldWidth: double.infinity, + enabled: !_isLoading, + ), + const SizedBox(height: 15), + + // Champ confirmation mot de passe + CustomAppTextField( + controller: _confirmPasswordController, + labelText: 'Confirmer le mot de passe', + hintText: 'Retapez le nouveau mot de passe', + obscureText: true, + validator: _validateConfirmPassword, + style: CustomAppTextFieldStyle.lavande, + fieldHeight: 53, + fieldWidth: double.infinity, + enabled: !_isLoading, + ), + const SizedBox(height: 10), + + // Critères du mot de passe + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Le mot de passe doit contenir :', + style: GoogleFonts.merienda( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 5), + _buildPasswordCriteria('Au moins 8 caractères'), + _buildPasswordCriteria('Au moins une majuscule'), + _buildPasswordCriteria('Au moins une minuscule'), + _buildPasswordCriteria('Au moins un chiffre'), + ], + ), + ), + + // Message d'erreur + if (_errorMessage != null) ...[ + const SizedBox(height: 15), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red[50], + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.red[300]!), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: Colors.red[700], size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + _errorMessage!, + style: GoogleFonts.merienda( + fontSize: 12, + color: Colors.red[700], + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 25), + + // Bouton de validation + Center( + child: _isLoading + ? const CircularProgressIndicator() + : ImageButton( + bg: 'assets/images/btn_green.png', + width: 250, + height: 40, + text: 'Changer le mot de passe', + textColor: const Color(0xFF2D6A4F), + onPressed: _handleSubmit, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildPasswordCriteria(String text) { + return Padding( + padding: const EdgeInsets.only(top: 3, left: 5), + child: Row( + children: [ + const Icon(Icons.check_circle_outline, size: 14, color: Colors.green), + const SizedBox(width: 8), + Text( + text, + style: GoogleFonts.merienda( + fontSize: 11, + color: Colors.black87, + ), + ), + ], + ), + ); + } +}