refactor(front): mutualiser les formulaires token password (#127)

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
This commit is contained in:
MARTIN Julien 2026-04-24 12:03:43 +02:00
parent 15123f691c
commit 644ba6c9c9
4 changed files with 339 additions and 437 deletions

View File

@ -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,
);
},
);
}
}

View File

@ -110,6 +110,8 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
autocorrect: false,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
validator: _validateEmail,
decoration: const InputDecoration(
labelText: 'E-mail',

View File

@ -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'),
),
],
);
}
}

View File

@ -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<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')) {
@ -101,128 +19,25 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
@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,
);
},
);
}
}