From 644ba6c9c967ff9db6d3239e0f9029f8f40b327f Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 24 Apr 2026 12:03:43 +0200 Subject: [PATCH] refactor(front): mutualiser les formulaires token password (#127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extrait un composant commun PasswordTokenFormScreen pour les pages create/reset password, conserve les comportements métier via wrappers (verify/create vs reset) et active la soumission sur Entrée dans mot de passe oublié. Made-with: Cursor --- .../screens/auth/create_password_screen.dart | 251 ++------------- .../screens/auth/forgot_password_screen.dart | 2 + .../auth/password_token_form_screen.dart | 296 ++++++++++++++++++ .../screens/auth/reset_password_screen.dart | 227 ++------------ 4 files changed, 339 insertions(+), 437 deletions(-) create mode 100644 frontend/lib/screens/auth/password_token_form_screen.dart diff --git a/frontend/lib/screens/auth/create_password_screen.dart b/frontend/lib/screens/auth/create_password_screen.dart index f9fe096..1326eb4 100644 --- a/frontend/lib/screens/auth/create_password_screen.dart +++ b/frontend/lib/screens/auth/create_password_screen.dart @@ -1,244 +1,33 @@ 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'; +import 'password_token_form_screen.dart'; -class CreatePasswordScreen extends StatefulWidget { +class CreatePasswordScreen extends StatelessWidget { final String? token; const CreatePasswordScreen({super.key, this.token}); - @override - State createState() => _CreatePasswordScreenState(); -} - -class _CreatePasswordScreenState extends State { - final _formKey = GlobalKey(); - final _passwordCtrl = TextEditingController(); - final _confirmCtrl = TextEditingController(); - - bool _checkingToken = true; - bool _submitting = false; - bool _tokenValid = false; - String? _message; - - String get _token => (widget.token ?? '').trim(); - - @override - void initState() { - super.initState(); - _checkToken(); - } - - @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; - } - - Future _checkToken() async { - if (_token.isEmpty) { - setState(() { - _checkingToken = false; - _tokenValid = false; - }); - return; - } - try { - final ok = await AuthService.verifyCreatePasswordToken(_token); - if (!mounted) return; - setState(() { - _checkingToken = false; - _tokenValid = ok; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _checkingToken = false; - _tokenValid = false; - _message = e.toString().replaceAll('Exception: ', ''); - }); - } - } - - Future _submit() async { - if (_submitting || !_tokenValid) return; - if (!(_formKey.currentState?.validate() ?? false)) return; - setState(() { - _submitting = true; - _message = null; - }); - try { - await AuthService.createPasswordWithToken( - token: _token, - password: _passwordCtrl.text, - passwordConfirmation: _confirmCtrl.text, - ); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Mot de passe créé avec succès. Vous pouvez vous connecter.', - style: GoogleFonts.merienda(), - ), - ), - ); - context.go('/login'); - } catch (e) { - if (!mounted) return; - setState(() { - _submitting = false; - _message = 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: 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: _checkingToken - ? const Center(child: CircularProgressIndicator()) - : _tokenValid - ? _buildForm() - : _buildInvalidToken(), - ), - ), - ), - ), - ), - ); - } - - Widget _buildForm() { - return Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Créer votre mot de passe', - style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w700), - textAlign: TextAlign.center, - ), - const SizedBox(height: 18), - TextFormField( - controller: _passwordCtrl, - obscureText: true, - validator: _validatePassword, - decoration: const InputDecoration( - labelText: 'Nouveau 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( - 'Le mot de passe doit contenir 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), - ), - ], - const SizedBox(height: 16), - _submitting - ? const Center(child: CircularProgressIndicator()) - : ImageButton( - bg: 'assets/images/bg_green.png', - width: double.infinity, - height: 44, - text: 'Valider', - textColor: const Color(0xFF2D6A4F), - onPressed: _submit, - ), - ], - ), - ); - } - - Widget _buildInvalidToken() { - final msg = _message ?? 'Lien invalide ou expiré.'; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Création du mot de passe', - style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w700), - textAlign: TextAlign.center, - ), - const SizedBox(height: 20), - Text( - msg, - 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'), - ), - ], + return PasswordTokenFormScreen( + token: token, + title: 'Créer votre mot de passe', + submitLabel: 'Valider', + successMessage: 'Mot de passe créé avec succès. Vous pouvez vous connecter.', + invalidTokenTitle: 'Création du mot de passe', + verifyToken: AuthService.verifyCreatePasswordToken, + onSubmit: ({ + required String token, + required String password, + required String passwordConfirmation, + }) { + return AuthService.createPasswordWithToken( + token: token, + password: password, + passwordConfirmation: passwordConfirmation, + ); + }, ); } } diff --git a/frontend/lib/screens/auth/forgot_password_screen.dart b/frontend/lib/screens/auth/forgot_password_screen.dart index f60c4f6..ca0342b 100644 --- a/frontend/lib/screens/auth/forgot_password_screen.dart +++ b/frontend/lib/screens/auth/forgot_password_screen.dart @@ -110,6 +110,8 @@ class _ForgotPasswordScreenState extends State { controller: _emailCtrl, keyboardType: TextInputType.emailAddress, autocorrect: false, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), validator: _validateEmail, decoration: const InputDecoration( labelText: 'E-mail', diff --git a/frontend/lib/screens/auth/password_token_form_screen.dart b/frontend/lib/screens/auth/password_token_form_screen.dart new file mode 100644 index 0000000..63b7722 --- /dev/null +++ b/frontend/lib/screens/auth/password_token_form_screen.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../widgets/image_button.dart'; + +typedef TokenPasswordSubmit = Future Function({ + required String token, + required String password, + required String passwordConfirmation, +}); + +typedef TokenVerifier = Future Function(String token); +typedef ErrorMapper = String Function(String rawError); + +/// Ecran commun pour les flows mot de passe basés sur un token URL. +class PasswordTokenFormScreen extends StatefulWidget { + final String? token; + final String title; + final String? subtitle; + final String submitLabel; + final String successMessage; + final String invalidTokenTitle; + final String invalidTokenMessage; + final TokenPasswordSubmit onSubmit; + final TokenVerifier? verifyToken; + final ErrorMapper? mapError; + + const PasswordTokenFormScreen({ + super.key, + required this.token, + required this.title, + this.subtitle, + required this.submitLabel, + required this.successMessage, + required this.invalidTokenTitle, + this.invalidTokenMessage = 'Lien invalide ou expiré.', + required this.onSubmit, + this.verifyToken, + this.mapError, + }); + + @override + State createState() => _PasswordTokenFormScreenState(); +} + +class _PasswordTokenFormScreenState extends State { + final _formKey = GlobalKey(); + final _passwordCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); + + bool _checkingToken = false; + bool _submitting = false; + bool _tokenValid = false; + String? _message; + + String get _token => (widget.token ?? '').trim(); + + @override + void initState() { + super.initState(); + _initTokenState(); + } + + @override + void dispose() { + _passwordCtrl.dispose(); + _confirmCtrl.dispose(); + super.dispose(); + } + + void _initTokenState() { + if (_token.isEmpty) { + _tokenValid = false; + _checkingToken = false; + return; + } + if (widget.verifyToken == null) { + _tokenValid = true; + _checkingToken = false; + return; + } + _checkingToken = true; + _verifyToken(); + } + + Future _verifyToken() async { + try { + final ok = await widget.verifyToken!(_token); + if (!mounted) return; + setState(() { + _checkingToken = false; + _tokenValid = ok; + }); + } catch (e) { + if (!mounted) return; + final raw = e.toString().replaceAll('Exception: ', ''); + setState(() { + _checkingToken = false; + _tokenValid = false; + _message = widget.mapError?.call(raw) ?? raw; + }); + } + } + + 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; + } + + Future _submit() async { + if (_submitting || !_tokenValid) return; + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() { + _submitting = true; + _message = null; + }); + try { + await widget.onSubmit( + token: _token, + password: _passwordCtrl.text, + passwordConfirmation: _confirmCtrl.text, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.successMessage, style: GoogleFonts.merienda()), + ), + ); + context.go('/login'); + } catch (e) { + if (!mounted) return; + final raw = e.toString().replaceAll('Exception: ', ''); + setState(() { + _submitting = false; + _message = widget.mapError?.call(raw) ?? 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: _checkingToken + ? const Center(child: CircularProgressIndicator()) + : _tokenValid + ? _buildForm(context) + : _buildInvalidToken(), + ), + ), + ), + ), + ), + ); + } + + Widget _buildForm(BuildContext context) { + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.title, + style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w700), + textAlign: TextAlign.center, + ), + if (widget.subtitle != null && widget.subtitle!.trim().isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + widget.subtitle!, + style: GoogleFonts.merienda(fontSize: 14, color: Colors.black87), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 18), + TextFormField( + controller: _passwordCtrl, + obscureText: true, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => FocusScope.of(context).nextFocus(), + validator: _validatePassword, + decoration: const InputDecoration( + labelText: 'Mot de passe', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 14), + TextFormField( + controller: _confirmCtrl, + obscureText: true, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + 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: widget.submitLabel, + textColor: const Color(0xFF2D6A4F), + onPressed: _submit, + ), + ], + ), + ); + } + + Widget _buildInvalidToken() { + final msg = _message ?? widget.invalidTokenMessage; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.invalidTokenTitle, + style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w700), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Text( + msg, + 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/screens/auth/reset_password_screen.dart b/frontend/lib/screens/auth/reset_password_screen.dart index b882761..ecfb06a 100644 --- a/frontend/lib/screens/auth/reset_password_screen.dart +++ b/frontend/lib/screens/auth/reset_password_screen.dart @@ -1,96 +1,14 @@ 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'; +import 'password_token_form_screen.dart'; /// Réinitialisation du mot de passe via lien e-mail (ticket #127, distinct de [CreatePasswordScreen]). -class ResetPasswordScreen extends StatefulWidget { +class ResetPasswordScreen extends StatelessWidget { 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')) { @@ -101,128 +19,25 @@ class _ResetPasswordScreenState extends State { @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'), - ), - ], + return PasswordTokenFormScreen( + token: token, + title: 'Nouveau mot de passe', + subtitle: 'Choisissez un mot de passe pour votre compte.', + submitLabel: 'Enregistrer', + successMessage: 'Mot de passe mis à jour. Vous pouvez vous connecter.', + invalidTokenTitle: 'Réinitialisation', + mapError: _neutralResetFeedback, + onSubmit: ({ + required String token, + required String password, + required String passwordConfirmation, + }) { + return AuthService.resetPasswordWithToken( + token: token, + password: password, + passwordConfirmation: passwordConfirmation, + ); + }, ); } }