feat(#127): mot de passe oublié — flux complet (API + web + e-mail)
- API publique POST /auth/forgot-password et /auth/reset-password (jeton password_reset_*, anti-énumération, e-mail P'titsPas) - BDD: colonnes password_reset_token / password_reset_expires + index - Front: écrans /forgot-password, /reset-password, lien login, mutualisation formulaires par token - Template e-mail + alignement couleur; tests unitaires (auth, mail) Merge depuis develop: branche feature/127. Made-with: Cursor
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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<CreatePasswordScreen> createState() => _CreatePasswordScreenState();
|
||||
}
|
||||
|
||||
class _CreatePasswordScreenState extends State<CreatePasswordScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<void> _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<void> _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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
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 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,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -355,7 +355,7 @@ class _LoginPageState extends State<LoginScreen> {
|
||||
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<LoginScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/create-password'),
|
||||
onPressed: () => context.go('/forgot-password'),
|
||||
child: Text(
|
||||
'Mot de passe oublié ?',
|
||||
style: GoogleFonts.merienda(
|
||||
|
||||
@@ -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<void> Function({
|
||||
required String token,
|
||||
required String password,
|
||||
required String passwordConfirmation,
|
||||
});
|
||||
|
||||
typedef TokenVerifier = Future<bool> 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<PasswordTokenFormScreen> createState() => _PasswordTokenFormScreenState();
|
||||
}
|
||||
|
||||
class _PasswordTokenFormScreenState extends State<PasswordTokenFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<void> _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<void> _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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../services/auth_service.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 StatelessWidget {
|
||||
final String? token;
|
||||
|
||||
const ResetPasswordScreen({super.key, this.token});
|
||||
|
||||
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 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<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
|
||||
static Future<void> logout() async {
|
||||
await TokenService.clearAll();
|
||||
|
||||
Reference in New Issue
Block a user