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:
parent
15123f691c
commit
644ba6c9c9
@ -1,244 +1,33 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 '../../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;
|
final String? token;
|
||||||
|
|
||||||
const CreatePasswordScreen({super.key, this.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return PasswordTokenFormScreen(
|
||||||
body: Container(
|
token: token,
|
||||||
decoration: const BoxDecoration(
|
title: 'Créer votre mot de passe',
|
||||||
image: DecorationImage(
|
submitLabel: 'Valider',
|
||||||
image: AssetImage('assets/images/paper2.png'),
|
successMessage: 'Mot de passe créé avec succès. Vous pouvez vous connecter.',
|
||||||
fit: BoxFit.cover,
|
invalidTokenTitle: 'Création du mot de passe',
|
||||||
repeat: ImageRepeat.repeat,
|
verifyToken: AuthService.verifyCreatePasswordToken,
|
||||||
),
|
onSubmit: ({
|
||||||
),
|
required String token,
|
||||||
child: Center(
|
required String password,
|
||||||
child: ConstrainedBox(
|
required String passwordConfirmation,
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
}) {
|
||||||
child: Card(
|
return AuthService.createPasswordWithToken(
|
||||||
color: const Color(0xF4FFFFFF),
|
token: token,
|
||||||
margin: const EdgeInsets.all(24),
|
password: password,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
passwordConfirmation: passwordConfirmation,
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -110,6 +110,8 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
controller: _emailCtrl,
|
controller: _emailCtrl,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) => _submit(),
|
||||||
validator: _validateEmail,
|
validator: _validateEmail,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'E-mail',
|
labelText: 'E-mail',
|
||||||
|
|||||||
296
frontend/lib/screens/auth/password_token_form_screen.dart
Normal file
296
frontend/lib/screens/auth/password_token_form_screen.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,96 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 '../../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]).
|
/// 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;
|
final String? token;
|
||||||
|
|
||||||
const ResetPasswordScreen({super.key, this.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) {
|
String _neutralResetFeedback(String raw) {
|
||||||
final l = raw.toLowerCase();
|
final l = raw.toLowerCase();
|
||||||
if (l.contains('token') || l.contains('invalide') || l.contains('expir')) {
|
if (l.contains('token') || l.contains('invalide') || l.contains('expir')) {
|
||||||
@ -101,128 +19,25 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return PasswordTokenFormScreen(
|
||||||
body: Container(
|
token: token,
|
||||||
decoration: const BoxDecoration(
|
title: 'Nouveau mot de passe',
|
||||||
image: DecorationImage(
|
subtitle: 'Choisissez un mot de passe pour votre compte.',
|
||||||
image: AssetImage('assets/images/paper2.png'),
|
submitLabel: 'Enregistrer',
|
||||||
fit: BoxFit.cover,
|
successMessage: 'Mot de passe mis à jour. Vous pouvez vous connecter.',
|
||||||
repeat: ImageRepeat.repeat,
|
invalidTokenTitle: 'Réinitialisation',
|
||||||
),
|
mapError: _neutralResetFeedback,
|
||||||
),
|
onSubmit: ({
|
||||||
child: Center(
|
required String token,
|
||||||
child: ConstrainedBox(
|
required String password,
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
required String passwordConfirmation,
|
||||||
child: Card(
|
}) {
|
||||||
color: const Color(0xF4FFFFFF),
|
return AuthService.resetPasswordWithToken(
|
||||||
margin: const EdgeInsets.all(24),
|
token: token,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
password: password,
|
||||||
child: Padding(
|
passwordConfirmation: passwordConfirmation,
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user