From e51e625d9383939b519bbceba2f501d7ed79621a Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 23 Apr 2026 17:02:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(front):=20mot=20de=20passe=20oubli=C3=A9?= =?UTF-8?q?=20=E2=80=94=20=C3=A9crans=20et=20API=20(#127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Routes /forgot-password et /reset-password (distinct de /create-password). - AuthService : POST forgot-password (réponse neutre côté UX) et reset-password. - Login : lien « Mot de passe oublié ? » vers la demande de réinitialisation. Made-with: Cursor --- frontend/lib/config/app_router.dart | 11 + .../screens/auth/forgot_password_screen.dart | 158 ++++++++++++ frontend/lib/screens/auth/login_screen.dart | 4 +- .../screens/auth/reset_password_screen.dart | 228 ++++++++++++++++++ frontend/lib/services/api/api_config.dart | 3 + frontend/lib/services/auth_service.dart | 72 ++++++ 6 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 frontend/lib/screens/auth/forgot_password_screen.dart create mode 100644 frontend/lib/screens/auth/reset_password_screen.dart diff --git a/frontend/lib/config/app_router.dart b/frontend/lib/config/app_router.dart index 8db296c..4dfe66e 100644 --- a/frontend/lib/config/app_router.dart +++ b/frontend/lib/config/app_router.dart @@ -19,6 +19,8 @@ import '../screens/auth/am_register_step2_screen.dart'; import '../screens/auth/am_register_step3_screen.dart'; import '../screens/auth/am_register_step4_screen.dart'; import '../screens/auth/create_password_screen.dart'; +import '../screens/auth/forgot_password_screen.dart'; +import '../screens/auth/reset_password_screen.dart'; import '../screens/home/home_screen.dart'; import '../screens/administrateurs/admin_dashboardScreen.dart'; import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart'; @@ -55,6 +57,15 @@ class AppRouter { builder: (BuildContext context, GoRouterState state) => CreatePasswordScreen(token: state.uri.queryParameters['token']), ), + GoRoute( + path: '/forgot-password', + builder: (BuildContext context, GoRouterState state) => const ForgotPasswordScreen(), + ), + GoRoute( + path: '/reset-password', + builder: (BuildContext context, GoRouterState state) => + ResetPasswordScreen(token: state.uri.queryParameters['token']), + ), GoRoute( path: '/home', builder: (BuildContext context, GoRouterState state) => const HomeScreen(), diff --git a/frontend/lib/screens/auth/forgot_password_screen.dart b/frontend/lib/screens/auth/forgot_password_screen.dart new file mode 100644 index 0000000..f60c4f6 --- /dev/null +++ b/frontend/lib/screens/auth/forgot_password_screen.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../services/auth_service.dart'; +import '../../utils/email_utils.dart'; +import '../../widgets/image_button.dart'; + +/// Demande de lien de réinitialisation (ticket #127). +class ForgotPasswordScreen extends StatefulWidget { + const ForgotPasswordScreen({super.key}); + + @override + State createState() => _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState extends State { + final _formKey = GlobalKey(); + final _emailCtrl = TextEditingController(); + + bool _submitting = false; + String? _fieldError; + + @override + void dispose() { + _emailCtrl.dispose(); + super.dispose(); + } + + String? _validateEmail(String? value) { + return validateEmail(value, allowEmpty: false); + } + + Future _submit() async { + if (_submitting) return; + setState(() => _fieldError = null); + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _submitting = true); + try { + await AuthService.requestForgotPassword(_emailCtrl.text); + if (!mounted) return; + setState(() => _submitting = false); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('Demande enregistrée', style: GoogleFonts.merienda(fontWeight: FontWeight.w700)), + content: Text( + 'Si un compte correspond à cette adresse, vous recevrez un e-mail ' + 'avec un lien pour réinitialiser votre mot de passe.', + style: GoogleFonts.merienda(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text('OK', style: GoogleFonts.merienda(color: const Color(0xFF2D6A4F))), + ), + ], + ), + ); + if (mounted) context.go('/login'); + } catch (e) { + if (!mounted) return; + setState(() { + _submitting = false; + _fieldError = e.toString().replaceAll('Exception: ', ''); + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/images/paper2.png'), + fit: BoxFit.cover, + repeat: ImageRepeat.repeat, + ), + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Card( + color: const Color(0xF4FFFFFF), + margin: const EdgeInsets.all(24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Mot de passe oublié', + style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w700), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'Indiquez l’adresse e-mail de votre compte. Nous vous enverrons un lien si elle est reconnue.', + style: GoogleFonts.merienda(fontSize: 14, color: Colors.black87), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + TextFormField( + controller: _emailCtrl, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + validator: _validateEmail, + decoration: const InputDecoration( + labelText: 'E-mail', + border: OutlineInputBorder(), + ), + ), + if (_fieldError != null) ...[ + const SizedBox(height: 12), + Text( + _fieldError!, + style: GoogleFonts.merienda(color: Colors.red.shade700, fontSize: 13), + ), + ], + const SizedBox(height: 20), + _submitting + ? const Center(child: CircularProgressIndicator()) + : ImageButton( + bg: 'assets/images/bg_green.png', + width: double.infinity, + height: 44, + text: 'Envoyer le lien', + textColor: const Color(0xFF2D6A4F), + onPressed: _submit, + ), + const SizedBox(height: 12), + TextButton( + onPressed: () => context.go('/login'), + child: Text( + 'Retour à la connexion', + style: GoogleFonts.merienda( + color: const Color(0xFF2D6A4F), + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index 4ad0b22..feb5476 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -355,7 +355,7 @@ class _LoginPageState extends State { Center( child: TextButton( onPressed: () { - context.go('/create-password'); + context.go('/forgot-password'); }, child: Text( 'Mot de passe oublié ?', @@ -618,7 +618,7 @@ class _LoginPageState extends State { ), const SizedBox(height: 12), TextButton( - onPressed: () => context.go('/create-password'), + onPressed: () => context.go('/forgot-password'), child: Text( 'Mot de passe oublié ?', style: GoogleFonts.merienda( diff --git a/frontend/lib/screens/auth/reset_password_screen.dart b/frontend/lib/screens/auth/reset_password_screen.dart new file mode 100644 index 0000000..b882761 --- /dev/null +++ b/frontend/lib/screens/auth/reset_password_screen.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../services/auth_service.dart'; +import '../../widgets/image_button.dart'; + +/// Réinitialisation du mot de passe via lien e-mail (ticket #127, distinct de [CreatePasswordScreen]). +class ResetPasswordScreen extends StatefulWidget { + final String? token; + + const ResetPasswordScreen({super.key, this.token}); + + @override + State createState() => _ResetPasswordScreenState(); +} + +class _ResetPasswordScreenState extends State { + final _formKey = GlobalKey(); + final _passwordCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); + + bool _submitting = false; + String? _message; + + String get _token => (widget.token ?? '').trim(); + + @override + void dispose() { + _passwordCtrl.dispose(); + _confirmCtrl.dispose(); + super.dispose(); + } + + bool _isStrongPassword(String v) { + if (v.length < 8) return false; + final hasUpper = RegExp(r'[A-Z]').hasMatch(v); + final hasDigit = RegExp(r'\d').hasMatch(v); + return hasUpper && hasDigit; + } + + String? _validatePassword(String? value) { + final v = value ?? ''; + if (v.isEmpty) return 'Veuillez saisir un mot de passe.'; + if (!_isStrongPassword(v)) { + return 'Minimum 8 caractères, avec au moins 1 majuscule et 1 chiffre.'; + } + return null; + } + + String? _validateConfirmation(String? value) { + final v = value ?? ''; + if (v.isEmpty) return 'Veuillez confirmer le mot de passe.'; + if (v != _passwordCtrl.text) return 'Les mots de passe ne correspondent pas.'; + return null; + } + + bool get _tokenPresent => _token.isNotEmpty; + + Future _submit() async { + if (_submitting || !_tokenPresent) return; + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() { + _submitting = true; + _message = null; + }); + try { + await AuthService.resetPasswordWithToken( + token: _token, + password: _passwordCtrl.text, + passwordConfirmation: _confirmCtrl.text, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Mot de passe mis à jour. Vous pouvez vous connecter.', + style: GoogleFonts.merienda(), + ), + ), + ); + context.go('/login'); + } catch (e) { + if (!mounted) return; + final raw = e.toString().replaceAll('Exception: ', ''); + setState(() { + _submitting = false; + _message = _neutralResetFeedback(raw); + }); + } + } + + /// Erreurs liées au lien : libellé unique neutre (#127). + String _neutralResetFeedback(String raw) { + final l = raw.toLowerCase(); + if (l.contains('token') || l.contains('invalide') || l.contains('expir')) { + return 'Lien invalide ou expiré.'; + } + return raw; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/images/paper2.png'), + fit: BoxFit.cover, + repeat: ImageRepeat.repeat, + ), + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Card( + color: const Color(0xF4FFFFFF), + margin: const EdgeInsets.all(24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(24), + child: _tokenPresent ? _buildForm() : _buildInvalidToken(), + ), + ), + ), + ), + ), + ); + } + + Widget _buildForm() { + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Nouveau mot de passe', + style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w700), + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + Text( + 'Choisissez un mot de passe pour votre compte.', + style: GoogleFonts.merienda(fontSize: 14, color: Colors.black87), + textAlign: TextAlign.center, + ), + const SizedBox(height: 18), + TextFormField( + controller: _passwordCtrl, + obscureText: true, + validator: _validatePassword, + decoration: const InputDecoration( + labelText: 'Mot de passe', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 14), + TextFormField( + controller: _confirmCtrl, + obscureText: true, + validator: _validateConfirmation, + decoration: const InputDecoration( + labelText: 'Confirmer le mot de passe', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 10), + Text( + 'Au moins 8 caractères, dont 1 majuscule et 1 chiffre.', + style: GoogleFonts.merienda(fontSize: 12, color: Colors.black54), + ), + if (_message != null) ...[ + const SizedBox(height: 12), + Text( + _message!, + style: GoogleFonts.merienda( + color: Colors.red.shade700, + fontSize: 13, + ), + ), + ], + const SizedBox(height: 16), + _submitting + ? const Center(child: CircularProgressIndicator()) + : ImageButton( + bg: 'assets/images/bg_green.png', + width: double.infinity, + height: 44, + text: 'Enregistrer', + textColor: const Color(0xFF2D6A4F), + onPressed: _submit, + ), + ], + ), + ); + } + + Widget _buildInvalidToken() { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Réinitialisation', + style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w700), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Text( + 'Lien invalide ou expiré.', + style: GoogleFonts.merienda(fontSize: 16), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + ImageButton( + bg: 'assets/images/bg_green.png', + width: double.infinity, + height: 44, + text: 'Retour à la connexion', + textColor: const Color(0xFF2D6A4F), + onPressed: () => context.go('/login'), + ), + ], + ); + } +} diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 34c88a2..11d6a28 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -46,6 +46,9 @@ class ApiConfig { static const String changePasswordRequired = '/auth/change-password-required'; static const String verifyCreatePasswordToken = '/auth/verify-token'; static const String createPassword = '/auth/create-password'; + /// Ticket #127 — mot de passe oublié (back à livrer en parallèle). + static const String forgotPassword = '/auth/forgot-password'; + static const String resetPassword = '/auth/reset-password'; // Users endpoints static const String users = '/users'; diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 321d2c4..94f5d28 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -12,6 +12,7 @@ import '../utils/parent_registration_payload.dart'; import 'api/api_config.dart'; import 'api/tokenService.dart'; import '../utils/nir_utils.dart'; +import '../utils/email_utils.dart'; class AuthService { static const String _currentUserKey = 'current_user'; @@ -186,6 +187,77 @@ class AuthService { throw Exception(_extractErrorMessage(decoded, response.statusCode)); } + /// Demande de réinitialisation du mot de passe (ticket #127). + /// Réponse **toujours neutre** côté UX si le serveur répond (anti-énumération des comptes). + static Future requestForgotPassword(String email) async { + final cleaned = normalizeEmailText(email); + if (cleaned.isEmpty) { + throw Exception('Veuillez saisir votre adresse e-mail.'); + } + late final http.Response response; + try { + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.forgotPassword}'), + headers: ApiConfig.headers, + body: jsonEncode({'email': cleaned}), + ); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.', + ); + } + if (response.statusCode >= 500) { + final decoded = _tryDecodeJsonMap(response.body); + throw Exception(_extractErrorMessage(decoded, response.statusCode)); + } + } + + /// Réinitialise le mot de passe via le lien e-mail (ticket #127). + static Future resetPasswordWithToken({ + required String token, + required String password, + required String passwordConfirmation, + }) async { + final cleaned = token.trim(); + if (cleaned.isEmpty) { + throw Exception('Lien invalide ou expiré.'); + } + late final http.Response response; + try { + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.resetPassword}'), + headers: ApiConfig.headers, + body: jsonEncode({ + 'token': cleaned, + 'password': password, + 'password_confirmation': passwordConfirmation, + }), + ); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.', + ); + } + + if (response.statusCode == 200 || response.statusCode == 201) { + return; + } + if (response.statusCode == 404) { + throw Exception('Lien invalide ou expiré.'); + } + final decoded = _tryDecodeJsonMap(response.body); + final msg = _extractErrorMessage(decoded, response.statusCode); + final lower = msg.toLowerCase(); + if (response.statusCode == 400 && + (lower.contains('token') || + lower.contains('invalide') || + lower.contains('expiré') || + lower.contains('expire'))) { + throw Exception('Lien invalide ou expiré.'); + } + throw Exception(msg); + } + /// Déconnexion de l'utilisateur static Future logout() async { await TokenService.clearAll();