diff --git a/frontend/lib/config/app_router.dart b/frontend/lib/config/app_router.dart index 074262f..8db296c 100644 --- a/frontend/lib/config/app_router.dart +++ b/frontend/lib/config/app_router.dart @@ -18,6 +18,7 @@ import '../screens/auth/am_register_step1_screen.dart'; 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/home/home_screen.dart'; import '../screens/administrateurs/admin_dashboardScreen.dart'; import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart'; @@ -49,6 +50,11 @@ class AppRouter { path: '/register-choice', builder: (BuildContext context, GoRouterState state) => const RegisterChoiceScreen(), ), + GoRoute( + path: '/create-password', + builder: (BuildContext context, GoRouterState state) => + CreatePasswordScreen(token: state.uri.queryParameters['token']), + ), GoRoute( path: '/home', builder: (BuildContext context, GoRouterState state) => const HomeScreen(), diff --git a/frontend/lib/screens/auth/create_password_screen.dart b/frontend/lib/screens/auth/create_password_screen.dart new file mode 100644 index 0000000..f9fe096 --- /dev/null +++ b/frontend/lib/screens/auth/create_password_screen.dart @@ -0,0 +1,244 @@ +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'; + +class CreatePasswordScreen extends StatefulWidget { + 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'), + ), + ], + ); + } +} diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index 16ee6d9..4ad0b22 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: () { - // TODO: Implémenter la logique de récupération de mot de passe + context.go('/create-password'); }, child: Text( 'Mot de passe oublié ?', @@ -618,7 +618,7 @@ class _LoginPageState extends State { ), const SizedBox(height: 12), TextButton( - onPressed: () {/* TODO */}, + onPressed: () => context.go('/create-password'), child: Text( 'Mot de passe oublié ?', style: GoogleFonts.merienda( diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index b3fe61d..34c88a2 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -44,6 +44,8 @@ class ApiConfig { static const String refreshToken = '/auth/refresh'; static const String authMe = '/auth/me'; static const String changePasswordRequired = '/auth/change-password-required'; + static const String verifyCreatePasswordToken = '/auth/verify-token'; + static const String createPassword = '/auth/create-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 629329b..321d2c4 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -131,6 +131,61 @@ class AuthService { } } + /// Vérifie qu'un token de création de mot de passe est encore valide. + /// Retourne `true` si l'API renvoie 200 ; `false` si 404. + static Future verifyCreatePasswordToken(String token) async { + final cleaned = token.trim(); + if (cleaned.isEmpty) return false; + try { + final uri = Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.verifyCreatePasswordToken}?token=${Uri.encodeQueryComponent(cleaned)}', + ); + final response = await http.get(uri, headers: ApiConfig.headers); + if (response.statusCode == 200) return true; + if (response.statusCode == 404) return false; + final decoded = _tryDecodeJsonMap(response.body); + throw Exception(_extractErrorMessage(decoded, response.statusCode)); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.', + ); + } + } + + /// Crée le mot de passe initial via token email. + static Future createPasswordWithToken({ + 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.createPassword}'), + 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; + } + final decoded = _tryDecodeJsonMap(response.body); + throw Exception(_extractErrorMessage(decoded, response.statusCode)); + } + /// Déconnexion de l'utilisateur static Future logout() async { await TokenService.clearAll();