feat(front): mot de passe oublié — écrans et API (#127)

- 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
This commit is contained in:
MARTIN Julien 2026-04-23 17:02:59 +02:00
parent e887562a1c
commit e51e625d93
6 changed files with 474 additions and 2 deletions

View File

@ -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_step3_screen.dart';
import '../screens/auth/am_register_step4_screen.dart'; import '../screens/auth/am_register_step4_screen.dart';
import '../screens/auth/create_password_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/home/home_screen.dart';
import '../screens/administrateurs/admin_dashboardScreen.dart'; import '../screens/administrateurs/admin_dashboardScreen.dart';
import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart'; import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart';
@ -55,6 +57,15 @@ class AppRouter {
builder: (BuildContext context, GoRouterState state) => builder: (BuildContext context, GoRouterState state) =>
CreatePasswordScreen(token: state.uri.queryParameters['token']), 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( GoRoute(
path: '/home', path: '/home',
builder: (BuildContext context, GoRouterState state) => const HomeScreen(), builder: (BuildContext context, GoRouterState state) => const HomeScreen(),

View File

@ -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<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _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<void>(
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 ladresse 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,
),
),
),
],
),
),
),
),
),
),
),
);
}
}

View File

@ -355,7 +355,7 @@ class _LoginPageState extends State<LoginScreen> {
Center( Center(
child: TextButton( child: TextButton(
onPressed: () { onPressed: () {
context.go('/create-password'); context.go('/forgot-password');
}, },
child: Text( child: Text(
'Mot de passe oublié ?', 'Mot de passe oublié ?',
@ -618,7 +618,7 @@ class _LoginPageState extends State<LoginScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextButton( TextButton(
onPressed: () => context.go('/create-password'), onPressed: () => context.go('/forgot-password'),
child: Text( child: Text(
'Mot de passe oublié ?', 'Mot de passe oublié ?',
style: GoogleFonts.merienda( style: GoogleFonts.merienda(

View File

@ -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<ResetPasswordScreen> createState() => _ResetPasswordScreenState();
}
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _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'),
),
],
);
}
}

View File

@ -46,6 +46,9 @@ class ApiConfig {
static const String changePasswordRequired = '/auth/change-password-required'; static const String changePasswordRequired = '/auth/change-password-required';
static const String verifyCreatePasswordToken = '/auth/verify-token'; static const String verifyCreatePasswordToken = '/auth/verify-token';
static const String createPassword = '/auth/create-password'; 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 // Users endpoints
static const String users = '/users'; static const String users = '/users';

View File

@ -12,6 +12,7 @@ import '../utils/parent_registration_payload.dart';
import 'api/api_config.dart'; import 'api/api_config.dart';
import 'api/tokenService.dart'; import 'api/tokenService.dart';
import '../utils/nir_utils.dart'; import '../utils/nir_utils.dart';
import '../utils/email_utils.dart';
class AuthService { class AuthService {
static const String _currentUserKey = 'current_user'; static const String _currentUserKey = 'current_user';
@ -186,6 +187,77 @@ class AuthService {
throw Exception(_extractErrorMessage(decoded, response.statusCode)); 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<void> 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<void> 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 /// Déconnexion de l'utilisateur
static Future<void> logout() async { static Future<void> logout() async {
await TokenService.clearAll(); await TokenService.clearAll();