From c43800928646c982de61e7f7c4d5ecf9cbe53e7d Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 16 Jun 2026 17:01:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(#112):=20lien=20login=20=C2=AB=20J'ai=20un?= =?UTF-8?q?=20num=C3=A9ro=20de=20dossier=20=C2=BB=20+=20reprise-identify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modale numéro + e-mail, obtention du token puis redirection vers /reprise. Co-authored-by: Cursor --- frontend/lib/screens/auth/login_screen.dart | 29 +++ frontend/lib/services/api/api_config.dart | 1 + frontend/lib/services/auth_service.dart | 45 +++++ .../widgets/auth/reprise_identify_dialog.dart | 168 ++++++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 frontend/lib/widgets/auth/reprise_identify_dialog.dart diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index feb5476..ab1c2e0 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -10,6 +10,7 @@ import '../../widgets/image_button.dart'; import '../../widgets/custom_app_text_field.dart'; import '../../services/auth_service.dart'; import '../../widgets/auth/change_password_dialog.dart'; +import '../../widgets/auth/reprise_identify_dialog.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -100,6 +101,31 @@ class _LoginPageState extends State { _handleLogin(); } + Future _openRepriseIdentify() async { + final token = await showDialog( + context: context, + builder: (ctx) => RepriseIdentifyDialog( + initialEmail: _emailController.text.trim(), + ), + ); + if (!mounted || token == null || token.isEmpty) return; + context.go('/reprise?token=${Uri.encodeComponent(token)}'); + } + + Widget _buildRepriseDossierLink({double fontSize = 14}) { + return TextButton( + onPressed: _isLoading ? null : _openRepriseIdentify, + child: Text( + 'J’ai un numéro de dossier', + style: GoogleFonts.merienda( + fontSize: fontSize, + color: const Color(0xFF2D6A4F), + decoration: TextDecoration.underline, + ), + ), + ); + } + /// Gère la connexion de l'utilisateur Future _handleLogin() async { // Réinitialiser le message d'erreur @@ -351,6 +377,8 @@ class _LoginPageState extends State { ), ), const SizedBox(height: 10), + Center(child: _buildRepriseDossierLink()), + const SizedBox(height: 10), // Lien mot de passe oublié Center( child: TextButton( @@ -617,6 +645,7 @@ class _LoginPageState extends State { onPressed: _handleLogin, ), const SizedBox(height: 12), + _buildRepriseDossierLink(), TextButton( onPressed: () => context.go('/forgot-password'), child: Text( diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 84f04a7..da671bc 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -52,6 +52,7 @@ class ApiConfig { /// Ticket #112 — reprise après refus (#111 back). static const String repriseDossier = '/auth/reprise-dossier'; static const String repriseResoumettre = '/auth/reprise-resoumettre'; + static const String repriseIdentify = '/auth/reprise-identify'; // Users endpoints static const String users = '/users'; diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 956339a..76c8f81 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -290,6 +290,51 @@ class AuthService { return RepriseDossier.fromJson(decoded); } + /// Modale login : numéro + e-mail → token reprise. POST /auth/reprise-identify. #112. + static Future identifyReprise({ + required String numeroDossier, + required String email, + }) async { + final num = numeroDossier.trim(); + final mail = normalizeEmailText(email); + if (num.isEmpty || mail.isEmpty) { + throw Exception('Numéro de dossier et e-mail requis.'); + } + late final http.Response response; + try { + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.repriseIdentify}'), + headers: ApiConfig.headers, + body: jsonEncode({ + 'numero_dossier': num, + 'email': mail, + }), + ); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.', + ); + } + if (response.statusCode == 404) { + throw Exception( + 'Aucun dossier en reprise trouvé pour ce numéro et cet e-mail.', + ); + } + if (response.statusCode != 200 && response.statusCode != 201) { + final decoded = _tryDecodeJsonMap(response.body); + throw Exception(_extractErrorMessage(decoded, response.statusCode)); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw Exception('Réponse invalide du serveur.'); + } + final token = decoded['token']?.toString().trim() ?? ''; + if (token.isEmpty) { + throw Exception('Réponse invalide du serveur.'); + } + return token; + } + /// Resoumission après refus. PATCH /auth/reprise-resoumettre. Ticket #112. static Future resoumettreReprise(Map body) async { final cleaned = body['token']?.toString().trim() ?? ''; diff --git a/frontend/lib/widgets/auth/reprise_identify_dialog.dart b/frontend/lib/widgets/auth/reprise_identify_dialog.dart new file mode 100644 index 0000000..da8130a --- /dev/null +++ b/frontend/lib/widgets/auth/reprise_identify_dialog.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../services/auth_service.dart'; +import '../../utils/email_utils.dart'; +import '../custom_app_text_field.dart'; + +/// Modale login : numéro de dossier + e-mail → token reprise (#112). +class RepriseIdentifyDialog extends StatefulWidget { + final String? initialEmail; + + const RepriseIdentifyDialog({super.key, this.initialEmail}); + + @override + State createState() => _RepriseIdentifyDialogState(); +} + +class _RepriseIdentifyDialogState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _numeroCtrl; + late final TextEditingController _emailCtrl; + + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + _numeroCtrl = TextEditingController(); + _emailCtrl = TextEditingController(text: widget.initialEmail ?? ''); + } + + @override + void dispose() { + _numeroCtrl.dispose(); + _emailCtrl.dispose(); + super.dispose(); + } + + String? _validateNumero(String? value) { + final v = value?.trim() ?? ''; + if (v.isEmpty) { + return 'Indiquez votre numéro de dossier.'; + } + return null; + } + + String? _validateEmail(String? value) { + final v = value?.trim() ?? ''; + if (v.isEmpty) { + return 'Indiquez votre adresse e-mail.'; + } + if (!isValidEmailFormat(v)) { + return 'L’adresse e-mail n’est pas valide.'; + } + return null; + } + + Future _submit() async { + if (_loading) return; + setState(() => _error = null); + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _loading = true); + try { + final token = await AuthService.identifyReprise( + numeroDossier: _numeroCtrl.text, + email: _emailCtrl.text, + ); + if (!mounted) return; + Navigator.of(context).pop(token); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Impossible de retrouver votre dossier.'; + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + 'Reprendre mon dossier', + style: GoogleFonts.merienda(fontWeight: FontWeight.bold), + ), + content: SizedBox( + width: 420, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Saisissez le numéro de dossier et l’e-mail utilisés lors ' + 'de l’inscription (dossier refusé en attente de correction).', + style: GoogleFonts.merienda(fontSize: 13), + ), + const SizedBox(height: 16), + CustomAppTextField( + controller: _numeroCtrl, + labelText: 'Numéro de dossier', + hintText: 'Ex. 2026-000021', + textInputAction: TextInputAction.next, + validator: _validateNumero, + style: CustomAppTextFieldStyle.lavande, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + const SizedBox(height: 12), + CustomAppTextField( + controller: _emailCtrl, + labelText: 'E-mail', + hintText: 'Votre adresse e-mail', + keyboardType: TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + inputFormatters: const [EmailMaxLengthFormatter()], + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: _validateEmail, + style: CustomAppTextFieldStyle.lavande, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: GoogleFonts.merienda( + fontSize: 12, + color: Colors.red.shade700, + ), + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _loading ? null : () => Navigator.of(context).pop(), + child: Text('Annuler', style: GoogleFonts.merienda()), + ), + TextButton( + onPressed: _loading ? null : _submit, + child: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + 'Continuer', + style: GoogleFonts.merienda( + fontWeight: FontWeight.bold, + color: const Color(0xFF2D6A4F), + ), + ), + ), + ], + ); + } +}