Files
geo/app/lib/presentation/widgets/user_form.dart
Pierre 5e255ebf5e feat: Implémentation authentification NIST SP 800-63B v3.0.8
- Ajout du service PasswordSecurityService conforme NIST SP 800-63B
- Vérification des mots de passe contre la base Have I Been Pwned
- Validation : minimum 8 caractères, maximum 64 caractères
- Pas d'exigences de composition obligatoires (conforme NIST)
- Intégration dans LoginController et UserController
- Génération de mots de passe sécurisés non compromis

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-15 15:31:23 +02:00

1016 lines
40 KiB
Dart
Executable File

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'dart:math';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'custom_text_field.dart';
class UserForm extends StatefulWidget {
final UserModel? user;
final Function(UserModel)? onSubmit;
final bool readOnly;
final bool allowUsernameEdit;
final bool allowSectNameEdit;
final AmicaleModel? amicale; // Nouveau paramètre pour l'amicale
final bool isAdmin; // Nouveau paramètre pour savoir si c'est un admin
const UserForm({
super.key,
this.user,
this.onSubmit,
this.readOnly = false,
this.allowUsernameEdit = false,
this.allowSectNameEdit = false,
this.amicale,
this.isAdmin = false,
});
@override
State<UserForm> createState() => _UserFormState();
}
class _UserFormState extends State<UserForm> {
final _formKey = GlobalKey<FormState>();
// Controllers
late final TextEditingController _usernameController;
late final TextEditingController _firstNameController;
late final TextEditingController _nameController;
late final TextEditingController _sectNameController;
late final TextEditingController _phoneController;
late final TextEditingController _mobileController;
late final TextEditingController _emailController;
late final TextEditingController _dateNaissanceController;
late final TextEditingController _dateEmbaucheController;
late final TextEditingController _passwordController; // Nouveau controller pour le mot de passe
// Form values
int _fkTitre = 1; // 1 = M., 2 = Mme
DateTime? _dateNaissance;
DateTime? _dateEmbauche;
// Pour la génération automatique d'username
bool _isGeneratingUsername = false;
final Random _random = Random();
// Pour afficher/masquer le mot de passe
bool _obscurePassword = true;
@override
void initState() {
super.initState();
// Initialize controllers with user data if available
final user = widget.user;
_usernameController = TextEditingController(text: user?.username ?? '');
_firstNameController = TextEditingController(text: user?.firstName ?? '');
_nameController = TextEditingController(text: user?.name ?? '');
_sectNameController = TextEditingController(text: user?.sectName ?? '');
_phoneController = TextEditingController(text: user?.phone ?? '');
_mobileController = TextEditingController(text: user?.mobile ?? '');
_emailController = TextEditingController(text: user?.email ?? '');
_dateNaissance = user?.dateNaissance;
_dateEmbauche = user?.dateEmbauche;
_dateNaissanceController = TextEditingController(text: _dateNaissance != null ? DateFormat('dd/MM/yyyy').format(_dateNaissance!) : '');
_dateEmbaucheController = TextEditingController(text: _dateEmbauche != null ? DateFormat('dd/MM/yyyy').format(_dateEmbauche!) : '');
_passwordController = TextEditingController(); // Initialiser le controller du mot de passe
_fkTitre = user?.fkTitre ?? 1;
// Ajouter des listeners pour auto-générer l'username en création
if (widget.user?.id == 0 && widget.isAdmin && widget.amicale?.chkUsernameManuel == true) {
_nameController.addListener(_onNameOrSectNameChanged);
_sectNameController.addListener(_onNameOrSectNameChanged);
}
}
void _onNameOrSectNameChanged() {
// Auto-générer username seulement en création et si le champ username est vide
if (widget.user?.id == 0 &&
_usernameController.text.isEmpty &&
(_nameController.text.isNotEmpty || _sectNameController.text.isNotEmpty)) {
_generateAndCheckUsername();
}
}
@override
void dispose() {
// Retirer les listeners si ajoutés
if (widget.user?.id == 0 && widget.isAdmin && widget.amicale?.chkUsernameManuel == true) {
_nameController.removeListener(_onNameOrSectNameChanged);
_sectNameController.removeListener(_onNameOrSectNameChanged);
}
_usernameController.dispose();
_firstNameController.dispose();
_nameController.dispose();
_sectNameController.dispose();
_phoneController.dispose();
_mobileController.dispose();
_emailController.dispose();
_dateNaissanceController.dispose();
_dateEmbaucheController.dispose();
_passwordController.dispose();
super.dispose();
}
// Validation conditionnelle pour name/sectName
String? _validateNameOrSectName(String? value, bool isNameField) {
final nameValue = _nameController.text.trim();
final sectNameValue = _sectNameController.text.trim();
// Si les deux sont vides
if (nameValue.isEmpty && sectNameValue.isEmpty) {
return isNameField ? "Veuillez renseigner soit le nom soit le nom de tournée" : "Veuillez renseigner soit le nom de tournée soit le nom";
}
return null; // Validation OK si au moins un des deux est rempli
}
// Méthode simplifiée pour sélectionner une date
void _selectDate(BuildContext context, bool isDateNaissance) {
// Utiliser un bloc try-catch pour capturer toutes les erreurs possibles
try {
// Déterminer la date initiale
DateTime initialDate;
if (isDateNaissance) {
initialDate = _dateNaissance ?? DateTime.now().subtract(const Duration(days: 365 * 30)); // 30 ans par défaut
} else {
initialDate = _dateEmbauche ?? DateTime.now();
}
// S'assurer que la date initiale est dans la plage autorisée
if (initialDate.isAfter(DateTime.now())) {
initialDate = DateTime.now();
}
if (initialDate.isBefore(DateTime(1900))) {
initialDate = DateTime(1950);
}
// Afficher le sélecteur de date avec locale française
showDatePicker(
context: context,
initialDate: initialDate,
firstDate: DateTime(1900),
lastDate: DateTime.now(),
locale: const Locale('fr', 'FR'), // Forcer la locale française
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: Theme.of(context).colorScheme.primary,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
helpText: isDateNaissance ? 'SÉLECTIONNER LA DATE DE NAISSANCE' : 'SÉLECTIONNER LA DATE D\'EMBAUCHE',
cancelText: 'ANNULER',
confirmText: 'VALIDER',
fieldLabelText: 'Entrer une date',
fieldHintText: 'jj/mm/aaaa',
errorFormatText: 'Format de date invalide',
errorInvalidText: 'Date invalide',
).then((DateTime? picked) {
// Vérifier si une date a été sélectionnée
if (picked != null) {
setState(() {
// Mettre à jour la date et le texte du contrôleur
if (isDateNaissance) {
_dateNaissance = picked;
_dateNaissanceController.text = DateFormat('dd/MM/yyyy').format(picked);
} else {
_dateEmbauche = picked;
_dateEmbaucheController.text = DateFormat('dd/MM/yyyy').format(picked);
}
});
}
}).catchError((error) {
// Gérer les erreurs spécifiques au sélecteur de date
debugPrint('Erreur lors de la sélection de la date: $error');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Erreur lors de la sélection de la date'),
backgroundColor: Colors.red,
),
);
});
} catch (e) {
// Gérer toutes les autres erreurs
debugPrint('Exception lors de l\'affichage du sélecteur de date: $e');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Impossible d\'afficher le sélecteur de date'),
backgroundColor: Colors.red,
),
);
}
}
// Nettoyer une chaîne pour l'username (NIST: garder plus de caractères)
String _cleanString(String input) {
// Avec NIST, on peut garder plus de caractères, mais pour la génération automatique
// on reste sur des caractères simples pour éviter les problèmes
return input.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9\s]'), ''); // Garder lettres, chiffres et espaces
}
// Extraire une partie aléatoire d'une chaîne
String _extractRandomPart(String input, int minLength, int maxLength) {
if (input.isEmpty) return '';
final cleaned = _cleanString(input);
if (cleaned.isEmpty) return '';
final length = minLength + _random.nextInt(maxLength - minLength + 1);
if (cleaned.length <= length) return cleaned;
// Prendre les premiers caractères jusqu'à la longueur désirée
return cleaned.substring(0, length);
}
// Générer un username selon les nouvelles normes NIST
String _generateUsername() {
// Récupérer les données nécessaires
final nom = _nameController.text.isNotEmpty ? _nameController.text : _sectNameController.text;
final codePostal = widget.amicale?.codePostal ?? '';
final ville = widget.amicale?.ville ?? '';
// Nettoyer et extraire les parties
final nomPart = _extractRandomPart(nom, 2, 5);
final cpPart = _extractRandomPart(codePostal, 2, 3);
final villePart = _extractRandomPart(ville, 2, 4);
final nombreAleatoire = 10 + _random.nextInt(990); // 10 à 999
// Choisir des séparateurs aléatoires (on peut maintenant utiliser des espaces aussi)
final separateurs = ['', '.', '_', '-', ' '];
final sep1 = separateurs[_random.nextInt(separateurs.length)];
final sep2 = separateurs[_random.nextInt(separateurs.length)];
// Assembler l'username
String username = '$nomPart$sep1$cpPart$sep2$villePart$nombreAleatoire';
// Si trop court, ajouter des chiffres pour atteindre minimum 8 caractères (NIST)
while (username.length < 8) {
username += _random.nextInt(10).toString();
}
// Avec NIST, on n'a plus besoin de nettoyer les caractères spéciaux
// Tous les caractères sont acceptés
return username;
}
// Vérifier la disponibilité d'un username via l'API
Future<Map<String, dynamic>> _checkUsernameAvailability(String username) async {
try {
final response = await ApiService.instance.post(
'/users/check-username',
data: {'username': username},
);
if (response.statusCode == 200) {
return response.data;
}
return {'available': false};
} catch (e) {
debugPrint('Erreur lors de la vérification de l\'username: $e');
return {'available': false};
}
}
// Générer et vérifier un username jusqu'à en trouver un disponible
Future<void> _generateAndCheckUsername() async {
if (_isGeneratingUsername) return; // Éviter les appels multiples
setState(() {
_isGeneratingUsername = true;
});
try {
int attempts = 0;
const maxAttempts = 10;
while (attempts < maxAttempts) {
final username = _generateUsername();
debugPrint('Tentative ${attempts + 1}: Vérification de $username');
final result = await _checkUsernameAvailability(username);
if (result['available'] == true) {
// Username disponible, l'utiliser
setState(() {
_usernameController.text = username;
});
debugPrint('✅ Username disponible trouvé: $username');
break;
} else {
// Si l'API propose des suggestions, essayer la première
if (result['suggestions'] != null && result['suggestions'].isNotEmpty) {
final suggestion = result['suggestions'][0];
debugPrint('Vérification de la suggestion: $suggestion');
final suggestionResult = await _checkUsernameAvailability(suggestion);
if (suggestionResult['available'] == true) {
setState(() {
_usernameController.text = suggestion;
});
debugPrint('✅ Suggestion disponible utilisée: $suggestion');
break;
}
}
}
attempts++;
}
if (attempts >= maxAttempts) {
debugPrint('⚠️ Impossible de trouver un username disponible après $maxAttempts tentatives');
}
} finally {
setState(() {
_isGeneratingUsername = false;
});
}
}
// Valider le mot de passe selon les normes NIST SP 800-63B
String? _validatePassword(String? value) {
if (value == null || value.isEmpty) {
// Pour un nouveau membre, le mot de passe est obligatoire si le champ est affiché
if (widget.user?.id == 0) {
return "Veuillez entrer un mot de passe";
}
return null; // En modification, vide = garder l'ancien
}
// Vérifier la longueur minimale (NIST: 8 caractères)
if (value.length < 8) {
return "Le mot de passe doit contenir au moins 8 caractères";
}
// Vérifier la longueur maximale (NIST: 64 caractères)
if (value.length > 64) {
return "Le mot de passe ne doit pas dépasser 64 caractères";
}
// NIST: Pas de vérification de composition obligatoire
// Les espaces sont autorisés, tous les caractères sont acceptés
// L'API vérifiera contre Have I Been Pwned
// Pas de vérification password == username (demande client)
return null;
}
// Générer un mot de passe selon les normes NIST (phrases de passe recommandées)
String _generatePassword() {
// Listes de mots pour créer des phrases de passe mémorables
final sujets = [
'Mon chat', 'Le chien', 'Ma voiture', 'Mon vélo', 'La maison',
'Mon jardin', 'Le soleil', 'La lune', 'Mon café', 'Le train',
'Ma pizza', 'Le gâteau', 'Mon livre', 'La musique', 'Mon film'
];
final noms = [
'Félix', 'Max', 'Luna', 'Bella', 'Charlie', 'Rocky', 'Maya',
'Oscar', 'Ruby', 'Leo', 'Emma', 'Jack', 'Sophie', 'Milo', 'Zoé'
];
final verbes = [
'aime', 'mange', 'court', 'saute', 'danse', 'chante', 'joue',
'dort', 'rêve', 'vole', 'nage', 'lit', 'écrit', 'peint', 'cuisine'
];
final complements = [
'dans le jardin', 'sous la pluie', 'avec joie', 'très vite', 'tout le temps',
'en été', 'le matin', 'la nuit', 'au soleil', 'dans la neige',
'sur la plage', 'à Paris', 'en vacances', 'avec passion', 'doucement'
];
// Choisir un type de phrase aléatoirement
final typePhrase = _random.nextInt(3);
String phrase;
switch (typePhrase) {
case 0:
// Type: Sujet + nom propre + verbe + complément
final sujet = sujets[_random.nextInt(sujets.length)];
final nom = noms[_random.nextInt(noms.length)];
final verbe = verbes[_random.nextInt(verbes.length)];
final complement = complements[_random.nextInt(complements.length)];
phrase = '$sujet $nom $verbe $complement';
break;
case 1:
// Type: Nom propre + a + nombre + ans + point d'exclamation
final nom = noms[_random.nextInt(noms.length)];
final age = 1 + _random.nextInt(20);
phrase = '$nom a $age ans!';
break;
default:
// Type: Sujet + verbe + nombre + complément
final sujet = sujets[_random.nextInt(sujets.length)];
final verbe = verbes[_random.nextInt(verbes.length)];
final nombre = 1 + _random.nextInt(100);
final complement = complements[_random.nextInt(complements.length)];
phrase = '$sujet $verbe $nombre fois $complement';
}
// Ajouter éventuellement un caractère spécial à la fin
if (_random.nextBool()) {
final speciaux = ['!', '?', '.', '...', '', '', '', ''];
phrase += speciaux[_random.nextInt(speciaux.length)];
}
// S'assurer que la phrase fait au moins 8 caractères (elle le sera presque toujours)
if (phrase.length < 8) {
phrase += ' ${1000 + _random.nextInt(9000)}';
}
// Tronquer si trop long (max 64 caractères selon NIST)
if (phrase.length > 64) {
phrase = phrase.substring(0, 64);
}
return phrase;
}
// Méthode publique pour récupérer le mot de passe si défini
String? getPassword() {
final password = _passwordController.text; // NIST: ne pas faire de trim, les espaces sont autorisés
return password.isNotEmpty ? password : null;
}
// Méthode publique pour valider et récupérer l'utilisateur
UserModel? validateAndGetUser() {
if (_formKey.currentState!.validate()) {
return widget.user?.copyWith(
username: _usernameController.text, // NIST: ne pas faire de trim sur username
firstName: _firstNameController.text.trim(),
name: _nameController.text.trim(),
sectName: _sectNameController.text.trim(),
phone: _phoneController.text.trim(),
mobile: _mobileController.text.trim(),
email: _emailController.text.trim(),
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
) ??
UserModel(
id: 0,
username: _usernameController.text, // NIST: ne pas faire de trim sur username
firstName: _firstNameController.text.trim(),
name: _nameController.text.trim(),
sectName: _sectNameController.text.trim(),
phone: _phoneController.text.trim(),
mobile: _mobileController.text.trim(),
email: _emailController.text.trim(),
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
role: 1,
createdAt: DateTime.now(),
lastSyncedAt: DateTime.now(),
);
}
return null;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isWideScreen = MediaQuery.of(context).size.width > 900;
// Déterminer si on doit afficher le champ username selon les règles
final bool shouldShowUsernameField = widget.isAdmin && widget.amicale?.chkUsernameManuel == true;
// Déterminer si le username est éditable (seulement en création, jamais en modification)
final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit && widget.user?.id == 0;
// Déterminer si on doit afficher le champ mot de passe
final bool shouldShowPasswordField = widget.isAdmin && widget.amicale?.chkMdpManuel == true;
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Email seul sur la première ligne
CustomTextField(
controller: _emailController,
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
isRequired: true,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer l'adresse email";
}
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
}
return null;
},
),
const SizedBox(height: 16),
// Titre (M. ou Mme)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Titre",
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Row(
children: [
_buildRadioOption(
value: 1,
label: 'M.',
groupValue: _fkTitre,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_fkTitre = value!;
});
},
),
const SizedBox(width: 40),
_buildRadioOption(
value: 2,
label: 'Mme',
groupValue: _fkTitre,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_fkTitre = value!;
});
},
),
],
),
],
),
const SizedBox(height: 16),
// Ligne 2: Prénom et Nom
if (isWideScreen)
Row(
children: [
Expanded(
child: CustomTextField(
controller: _firstNameController,
label: "Prénom",
readOnly: widget.readOnly,
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _nameController,
label: "Nom",
readOnly: widget.readOnly,
validator: (value) => _validateNameOrSectName(value, true),
onChanged: (value) {
// Revalider sectName quand name change
if (widget.allowSectNameEdit) {
_formKey.currentState?.validate();
}
},
),
),
],
)
else ...[
// Version mobile: Prénom et nom séparés
CustomTextField(
controller: _firstNameController,
label: "Prénom",
readOnly: widget.readOnly,
),
const SizedBox(height: 16),
CustomTextField(
controller: _nameController,
label: "Nom",
readOnly: widget.readOnly,
validator: (value) => _validateNameOrSectName(value, true),
onChanged: (value) {
// Revalider sectName quand name change
if (widget.allowSectNameEdit) {
_formKey.currentState?.validate();
}
},
),
],
const SizedBox(height: 16),
// Ligne 2.5: Nom de tournée (sectName) - uniquement si éditable
if (widget.allowSectNameEdit) ...[
CustomTextField(
controller: _sectNameController,
label: "Nom de tournée",
readOnly: widget.readOnly,
validator: (value) => _validateNameOrSectName(value, false),
onChanged: (value) {
// Revalider name quand sectName change
_formKey.currentState?.validate();
},
hintText: "Nom utilisé pour identifier la tournée",
),
const SizedBox(height: 16),
],
// Ligne 2: Téléphones (fixe et mobile)
if (isWideScreen)
Row(
children: [
Expanded(
child: CustomTextField(
controller: _phoneController,
label: "Téléphone fixe",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro doit contenir 10 chiffres";
}
return null;
},
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _mobileController,
label: "Téléphone mobile",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro doit contenir 10 chiffres";
}
return null;
},
),
),
],
)
else ...[
// Version mobile: Téléphones séparés
CustomTextField(
controller: _phoneController,
label: "Téléphone fixe",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro doit contenir 10 chiffres";
}
return null;
},
),
const SizedBox(height: 16),
CustomTextField(
controller: _mobileController,
label: "Téléphone mobile",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro doit contenir 10 chiffres";
}
return null;
},
),
],
const SizedBox(height: 16),
// Ligne 3: Username et Password (si applicable)
if (shouldShowUsernameField || shouldShowPasswordField) ...[
if (isWideScreen)
Row(
children: [
if (shouldShowUsernameField)
Expanded(
child: CustomTextField(
controller: _usernameController,
label: "Nom d'utilisateur",
readOnly: !canEditUsername,
prefixIcon: Icons.account_circle,
isRequired: canEditUsername,
suffixIcon: (widget.user?.id == 0 && canEditUsername)
? _isGeneratingUsername
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
)
: IconButton(
icon: Icon(Icons.refresh),
onPressed: _generateAndCheckUsername,
tooltip: "Générer un nom d'utilisateur",
)
: null,
helperText: canEditUsername
? "8 à 64 caractères. Tous les caractères sont acceptés, y compris les espaces et accents."
: null,
helperMaxLines: 2,
validator: canEditUsername
? (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom d'utilisateur";
}
// Vérifier la longueur minimale (NIST: 8 caractères)
if (value.length < 8) {
return "Le nom d'utilisateur doit contenir au moins 8 caractères";
}
// Vérifier la longueur maximale (NIST: 64 caractères)
if (value.length > 64) {
return "Le nom d'utilisateur ne doit pas dépasser 64 caractères";
}
// Pas de vérification sur le type de caractères (NIST: tous acceptés)
return null;
}
: null,
),
),
if (shouldShowUsernameField && shouldShowPasswordField)
const SizedBox(width: 16),
if (shouldShowPasswordField)
Expanded(
child: CustomTextField(
controller: _passwordController,
label: "Mot de passe",
obscureText: _obscurePassword,
readOnly: widget.readOnly,
prefixIcon: Icons.lock,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton pour afficher/masquer le mot de passe
IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
// Bouton pour générer un mot de passe (seulement si éditable)
if (!widget.readOnly)
IconButton(
icon: Icon(Icons.auto_awesome),
onPressed: () {
final newPassword = _generatePassword();
setState(() {
_passwordController.text = newPassword;
_obscurePassword = false; // Afficher le mot de passe généré
});
// Revalider le formulaire
_formKey.currentState?.validate();
},
tooltip: "Générer un mot de passe sécurisé",
),
],
),
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
: "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)",
helperMaxLines: 3,
validator: _validatePassword,
),
),
// Si seulement un des deux est affiché, ajouter un Expanded vide pour garder l'alignement
if ((shouldShowUsernameField && !shouldShowPasswordField) || (!shouldShowUsernameField && shouldShowPasswordField))
const Expanded(child: SizedBox()),
],
)
else ...[
// Version mobile: Username et Password séparés
if (shouldShowUsernameField) ...[
CustomTextField(
controller: _usernameController,
label: "Nom d'utilisateur",
readOnly: !canEditUsername,
prefixIcon: Icons.account_circle,
isRequired: canEditUsername,
suffixIcon: (widget.user?.id == 0 && canEditUsername)
? _isGeneratingUsername
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
)
: IconButton(
icon: Icon(Icons.refresh),
onPressed: _generateAndCheckUsername,
tooltip: "Générer un nom d'utilisateur",
)
: null,
helperText: canEditUsername
? "8 à 64 caractères. Tous les caractères sont acceptés, y compris les espaces et accents."
: null,
helperMaxLines: 2,
validator: canEditUsername
? (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom d'utilisateur";
}
// Vérifier la longueur minimale (NIST: 8 caractères)
if (value.length < 8) {
return "Le nom d'utilisateur doit contenir au moins 8 caractères";
}
// Vérifier la longueur maximale (NIST: 64 caractères)
if (value.length > 64) {
return "Le nom d'utilisateur ne doit pas dépasser 64 caractères";
}
// Pas de vérification sur le type de caractères (NIST: tous acceptés)
return null;
}
: null,
),
const SizedBox(height: 16),
],
if (shouldShowPasswordField) ...[
CustomTextField(
controller: _passwordController,
label: "Mot de passe",
obscureText: _obscurePassword,
readOnly: widget.readOnly,
prefixIcon: Icons.lock,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton pour afficher/masquer le mot de passe
IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
// Bouton pour générer un mot de passe (seulement si éditable)
if (!widget.readOnly)
IconButton(
icon: Icon(Icons.auto_awesome),
onPressed: () {
final newPassword = _generatePassword();
setState(() {
_passwordController.text = newPassword;
_obscurePassword = false; // Afficher le mot de passe généré
});
// Revalider le formulaire
_formKey.currentState?.validate();
},
tooltip: "Générer un mot de passe sécurisé",
),
],
),
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
: "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)",
helperMaxLines: 3,
validator: _validatePassword,
),
const SizedBox(height: 16),
],
],
const SizedBox(height: 16),
],
// Ligne 4: Dates (naissance et embauche)
if (isWideScreen)
Row(
children: [
Expanded(
child: CustomTextField(
controller: _dateNaissanceController,
label: "Date de naissance",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, true),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _dateEmbaucheController,
label: "Date d'embauche",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, false),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
),
],
)
else ...[
// Version mobile: Dates séparées
CustomTextField(
controller: _dateNaissanceController,
label: "Date de naissance",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, true),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 16),
CustomTextField(
controller: _dateEmbaucheController,
label: "Date d'embauche",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, false),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
],
const SizedBox(height: 16),
],
),
);
}
Widget _buildRadioOption({
required int value,
required String label,
required int groupValue,
required Function(int?)? onChanged,
}) {
final theme = Theme.of(context);
final isSelected = value == groupValue;
return Row(
children: [
Radio<int>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
activeColor: const Color(0xFF20335E),
),
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
],
);
}
}
// Exporter la classe State pour pouvoir l'utiliser avec GlobalKey
typedef UserFormState = _UserFormState;