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>
This commit is contained in:
@@ -6,6 +6,7 @@ class CustomTextField extends StatelessWidget {
|
||||
final String label;
|
||||
final String? hintText;
|
||||
final String? helperText;
|
||||
final int? helperMaxLines;
|
||||
final IconData? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
final bool readOnly;
|
||||
@@ -33,6 +34,7 @@ class CustomTextField extends StatelessWidget {
|
||||
required this.label,
|
||||
this.hintText,
|
||||
this.helperText,
|
||||
this.helperMaxLines,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.readOnly = false,
|
||||
@@ -79,6 +81,7 @@ class CustomTextField extends StatelessWidget {
|
||||
labelText: isRequired ? "$label *" : label,
|
||||
hintText: hintText,
|
||||
helperText: helperText,
|
||||
helperMaxLines: helperMaxLines,
|
||||
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
|
||||
suffixIcon: suffixIcon,
|
||||
border: const OutlineInputBorder(),
|
||||
@@ -150,6 +153,7 @@ class CustomTextField extends StatelessWidget {
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
helperText: helperText,
|
||||
helperMaxLines: helperMaxLines,
|
||||
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
|
||||
suffixIcon: suffixIcon,
|
||||
border: OutlineInputBorder(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:geosector_app/core/services/app_info_service.dart';
|
||||
import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:geosector_app/presentation/widgets/user_form_dialog.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
|
||||
@@ -42,7 +44,10 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Vérifier si le logo de l'amicale est présent pour ajuster la largeur du leading
|
||||
final amicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
final hasAmicaleLogo = amicale?.logoBase64 != null && amicale!.logoBase64!.isNotEmpty;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -52,6 +57,8 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
elevation: 4,
|
||||
leading: _buildLogo(),
|
||||
// Ajuster la largeur du leading si le logo de l'amicale est présent
|
||||
leadingWidth: hasAmicaleLogo ? 110 : 56, // 56 par défaut, 110 pour 2 logos + espacement
|
||||
actions: _buildActions(context),
|
||||
),
|
||||
// Bordure colorée selon le rôle
|
||||
@@ -65,6 +72,9 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
|
||||
/// Construction du logo dans l'AppBar
|
||||
Widget _buildLogo() {
|
||||
final amicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
final logoBase64 = amicale?.logoBase64;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
@@ -75,11 +85,56 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
width: 40,
|
||||
height: 40,
|
||||
),
|
||||
// Afficher le logo de l'amicale s'il est disponible
|
||||
if (logoBase64 != null && logoBase64.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
_buildAmicaleLogo(logoBase64),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du logo de l'amicale à partir du Base64
|
||||
Widget _buildAmicaleLogo(String logoBase64) {
|
||||
try {
|
||||
// Le logoBase64 peut être au format "data:image/png;base64,..." ou juste la chaîne base64
|
||||
String base64String = logoBase64;
|
||||
if (logoBase64.contains('base64,')) {
|
||||
base64String = logoBase64.split('base64,').last;
|
||||
}
|
||||
|
||||
final decodedBytes = base64Decode(base64String);
|
||||
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: Colors.white,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image.memory(
|
||||
decodedBytes,
|
||||
width: 40,
|
||||
height: 40,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// En cas d'erreur de décodage, ne rien afficher
|
||||
debugPrint('Erreur lors du chargement du logo amicale: $error');
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// En cas d'erreur, ne rien afficher
|
||||
debugPrint('Erreur lors du décodage du logo amicale: $e');
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
/// Construction des actions de l'AppBar
|
||||
List<Widget> _buildActions(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -263,9 +318,6 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
return Text(title);
|
||||
}
|
||||
|
||||
// Construire un titre composé en fonction du rôle de l'utilisateur
|
||||
final String prefix = isAdmin ? 'Administration' : title;
|
||||
|
||||
// Utiliser LayoutBuilder pour détecter la largeur disponible
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -274,20 +326,14 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final isMobilePlatform = Theme.of(context).platform == TargetPlatform.android ||
|
||||
Theme.of(context).platform == TargetPlatform.iOS;
|
||||
|
||||
// Cacher le titre de page sur mobile ou écrans étroits
|
||||
// Sur mobile ou écrans étroits, afficher seulement le titre principal
|
||||
if (isNarrowScreen || isMobilePlatform) {
|
||||
return Text(prefix);
|
||||
return Text(title);
|
||||
}
|
||||
|
||||
// Afficher le titre complet sur écrans larges (web desktop)
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(prefix),
|
||||
const Text(' - '),
|
||||
Text(pageTitle!),
|
||||
],
|
||||
);
|
||||
// Sur écrans larges (web desktop), afficher le titre de la page ou le titre principal
|
||||
// Pour les admins, on affiche directement le titre de la page sans préfixe
|
||||
return Text(pageTitle!);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,14 +155,25 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
/// Construction de la barre de navigation inférieure pour mobile
|
||||
Widget _buildBottomNavigationBar() {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Définir les couleurs selon le rôle (admin = rouge, user = vert)
|
||||
final Color selectedColor = widget.isAdmin ? Colors.red : Colors.green;
|
||||
|
||||
return NavigationBar(
|
||||
selectedIndex: widget.selectedIndex,
|
||||
onDestinationSelected: widget.onDestinationSelected,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
elevation: 8,
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
|
||||
destinations: widget.destinations,
|
||||
return Theme(
|
||||
data: theme.copyWith(
|
||||
colorScheme: theme.colorScheme.copyWith(
|
||||
onSecondaryContainer: selectedColor, // Couleur de l'icône sélectionnée
|
||||
secondaryContainer: selectedColor.withOpacity(0.15), // Couleur de fond de l'indicateur
|
||||
),
|
||||
),
|
||||
child: NavigationBar(
|
||||
selectedIndex: widget.selectedIndex,
|
||||
onDestinationSelected: widget.onDestinationSelected,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
elevation: 8,
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
|
||||
destinations: widget.destinations,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -345,6 +356,10 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
final theme = Theme.of(context);
|
||||
final isSelected = widget.selectedIndex == index;
|
||||
final IconData? iconData = (icon is Icon) ? (icon).icon : null;
|
||||
|
||||
// Définir les couleurs selon le rôle (admin = rouge, user = vert)
|
||||
final Color selectedColor = widget.isAdmin ? Colors.red : Colors.green;
|
||||
final Color unselectedColor = theme.colorScheme.onSurface.withOpacity(0.6);
|
||||
|
||||
// Remplacer certains titres si l'interface est de type "user"
|
||||
String displayTitle = title;
|
||||
@@ -371,16 +386,14 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary.withOpacity(0.1)
|
||||
? selectedColor.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: iconData != null
|
||||
? Icon(
|
||||
iconData,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
color: isSelected ? selectedColor : unselectedColor,
|
||||
size: 24,
|
||||
)
|
||||
: icon,
|
||||
@@ -394,22 +407,18 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
leading: iconData != null
|
||||
? Icon(
|
||||
iconData,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
color: isSelected ? selectedColor : unselectedColor,
|
||||
)
|
||||
: icon,
|
||||
title: Text(
|
||||
displayTitle,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface,
|
||||
color: isSelected ? selectedColor : theme.colorScheme.onSurface,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
tileColor:
|
||||
isSelected ? theme.colorScheme.primary.withOpacity(0.1) : null,
|
||||
isSelected ? selectedColor.withOpacity(0.1) : null,
|
||||
onTap: () {
|
||||
widget.onDestinationSelected(index);
|
||||
},
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
|
||||
class SectorDistributionCard extends StatelessWidget {
|
||||
// Enum pour les types de tri
|
||||
enum SortType { name, count, progress }
|
||||
enum SortOrder { none, asc, desc }
|
||||
|
||||
class SectorDistributionCard extends StatefulWidget {
|
||||
final String title;
|
||||
final double? height;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
@@ -17,11 +23,81 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SectorDistributionCard> createState() => _SectorDistributionCardState();
|
||||
}
|
||||
|
||||
class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
SortType? _currentSortType;
|
||||
SortOrder _currentSortOrder = SortOrder.none;
|
||||
|
||||
void _onSortPressed(SortType sortType) {
|
||||
setState(() {
|
||||
if (_currentSortType == sortType) {
|
||||
// Cycle through: none -> asc -> desc -> none
|
||||
if (_currentSortOrder == SortOrder.none) {
|
||||
_currentSortOrder = SortOrder.asc;
|
||||
} else if (_currentSortOrder == SortOrder.asc) {
|
||||
_currentSortOrder = SortOrder.desc;
|
||||
} else {
|
||||
_currentSortOrder = SortOrder.none;
|
||||
_currentSortType = null;
|
||||
}
|
||||
} else {
|
||||
_currentSortType = sortType;
|
||||
_currentSortOrder = SortOrder.asc;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildSortButton(String label, SortType sortType) {
|
||||
final isActive = _currentSortType == sortType && _currentSortOrder != SortOrder.none;
|
||||
final isAsc = _currentSortType == sortType && _currentSortOrder == SortOrder.asc;
|
||||
final isDesc = _currentSortType == sortType && _currentSortOrder == SortOrder.desc;
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _onSortPressed(sortType),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? Colors.blue.withOpacity(0.1) : Colors.grey.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isActive ? Colors.blue : Colors.grey[400]!,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? Colors.blue : Colors.grey[700],
|
||||
),
|
||||
),
|
||||
if (isActive) ...[
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
isAsc ? Icons.arrow_upward : Icons.arrow_downward,
|
||||
size: 12,
|
||||
color: Colors.blue,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: height,
|
||||
padding: padding ?? const EdgeInsets.all(AppTheme.spacingM),
|
||||
height: widget.height,
|
||||
padding: widget.padding ?? const EdgeInsets.all(AppTheme.spacingM),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
@@ -30,12 +106,29 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
// Ligne du titre avec boutons de tri
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
// Boutons de tri groupés
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildSortButton('Nom', SortType.name),
|
||||
const SizedBox(width: 4),
|
||||
_buildSortButton('Nb', SortType.count),
|
||||
const SizedBox(width: 4),
|
||||
_buildSortButton('%', SortType.progress),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Expanded(
|
||||
@@ -75,6 +168,16 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Appliquer le tri
|
||||
_applySorting(sectorStats);
|
||||
|
||||
// Trouver le maximum de passages pour un secteur
|
||||
final maxCount = sectorStats.fold<int>(
|
||||
0,
|
||||
(max, sector) => sector['count'] > max ? sector['count'] : max,
|
||||
);
|
||||
|
||||
// Liste des secteurs directement sans sous-titre
|
||||
return ListView.builder(
|
||||
itemCount: sectorStats.length,
|
||||
itemBuilder: (context, index) {
|
||||
@@ -84,6 +187,7 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
sector['count'],
|
||||
Color(sector['color']),
|
||||
sectorStats,
|
||||
maxCount, // Passer le max pour calculer les proportions
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -103,83 +207,281 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
final List<SectorModel> sectors = sectorsBox.values.toList();
|
||||
final List<PassageModel> passages = passagesBox.values.toList();
|
||||
|
||||
// Compter les passages par secteur (en excluant ceux où fkType==2)
|
||||
final Map<int, int> sectorCounts = {};
|
||||
|
||||
for (final passage in passages) {
|
||||
// Exclure les passages où fkType==2 et ceux sans secteur
|
||||
if (passage.fkType != 2 && passage.fkSector != null) {
|
||||
sectorCounts[passage.fkSector!] =
|
||||
(sectorCounts[passage.fkSector!] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Préparer les données pour l'affichage
|
||||
// Préparer les données pour l'affichage - AFFICHER TOUS LES SECTEURS
|
||||
List<Map<String, dynamic>> stats = [];
|
||||
|
||||
for (final sector in sectors) {
|
||||
final count = sectorCounts[sector.id] ?? 0;
|
||||
if (count > 0) {
|
||||
stats.add({
|
||||
'name': sector.libelle,
|
||||
'count': count,
|
||||
'color': sector.color.isEmpty
|
||||
? 0xFF4B77BE
|
||||
: int.tryParse(sector.color.replaceAll('#', '0xFF')) ??
|
||||
0xFF4B77BE,
|
||||
});
|
||||
// Compter les passages par type pour ce secteur
|
||||
Map<int, int> passagesByType = {};
|
||||
int totalCount = 0;
|
||||
int passagesNotType2 = 0;
|
||||
|
||||
// Compter tous les passages pour ce secteur
|
||||
for (final passage in passages) {
|
||||
if (passage.fkSector == sector.id) {
|
||||
final type = passage.fkType;
|
||||
passagesByType[type] = (passagesByType[type] ?? 0) + 1;
|
||||
totalCount++;
|
||||
if (type != 2) {
|
||||
passagesNotType2++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculer le pourcentage d'avancement
|
||||
final int progressPercentage = totalCount > 0
|
||||
? ((passagesNotType2 / totalCount) * 100).round()
|
||||
: 0;
|
||||
|
||||
stats.add({
|
||||
'id': sector.id,
|
||||
'name': sector.libelle,
|
||||
'count': totalCount,
|
||||
'passagesByType': passagesByType,
|
||||
'progressPercentage': progressPercentage,
|
||||
'color': sector.color.isEmpty
|
||||
? 0xFF4B77BE
|
||||
: int.tryParse(sector.color.replaceAll('#', '0xFF')) ??
|
||||
0xFF4B77BE,
|
||||
});
|
||||
}
|
||||
|
||||
// Trier par nombre de passages (décroissant)
|
||||
stats.sort((a, b) => (b['count'] as int).compareTo(a['count'] as int));
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
void _applySorting(List<Map<String, dynamic>> stats) {
|
||||
if (_currentSortType == null || _currentSortOrder == SortOrder.none) {
|
||||
// Tri par défaut : par nombre de passages décroissant, puis par nom
|
||||
stats.sort((a, b) {
|
||||
int countCompare = (b['count'] as int).compareTo(a['count'] as int);
|
||||
if (countCompare != 0) return countCompare;
|
||||
return (a['name'] as String).compareTo(b['name'] as String);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
switch (_currentSortType!) {
|
||||
case SortType.name:
|
||||
stats.sort((a, b) {
|
||||
final result = (a['name'] as String).compareTo(b['name'] as String);
|
||||
return _currentSortOrder == SortOrder.asc ? result : -result;
|
||||
});
|
||||
break;
|
||||
case SortType.count:
|
||||
stats.sort((a, b) {
|
||||
final result = (a['count'] as int).compareTo(b['count'] as int);
|
||||
return _currentSortOrder == SortOrder.asc ? result : -result;
|
||||
});
|
||||
break;
|
||||
case SortType.progress:
|
||||
stats.sort((a, b) {
|
||||
final result = (a['progressPercentage'] as int).compareTo(b['progressPercentage'] as int);
|
||||
return _currentSortOrder == SortOrder.asc ? result : -result;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSectorItem(
|
||||
String name,
|
||||
int count,
|
||||
Color color,
|
||||
List<Map<String, dynamic>> allStats,
|
||||
int maxCount,
|
||||
) {
|
||||
final totalCount =
|
||||
allStats.fold(0, (sum, item) => sum + (item['count'] as int));
|
||||
final percentage = totalCount > 0 ? (count / totalCount) * 100 : 0;
|
||||
// Récupérer les données du secteur actuel
|
||||
final sectorData = allStats.firstWhere((s) => s['name'] == name);
|
||||
final Map<int, int> passagesByType = sectorData['passagesByType'] ?? {};
|
||||
final int progressPercentage = sectorData['progressPercentage'] ?? 0;
|
||||
final int sectorId = sectorData['id'] ?? 0;
|
||||
|
||||
// Calculer le ratio par rapport au maximum (éviter division par zéro)
|
||||
final double widthRatio = maxCount > 0 ? count / maxCount : 0;
|
||||
|
||||
// Style différent pour les secteurs sans passages
|
||||
final bool hasPassages = count > 0;
|
||||
final textColor = hasPassages ? Colors.black87 : Colors.grey;
|
||||
|
||||
// Vérifier si l'utilisateur est admin
|
||||
final bool isAdmin = CurrentUserService.instance.canAccessAdmin;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppTheme.spacingS),
|
||||
padding: const EdgeInsets.only(bottom: AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Nom du secteur et total
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: isAdmin
|
||||
? InkWell(
|
||||
onTap: () {
|
||||
// Sauvegarder le secteur sélectionné et l'index de la page carte dans Hive
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.put('admin_selectedSectorId', sectorId);
|
||||
settingsBox.put('adminSelectedPageIndex', 4); // Index de la page carte
|
||||
|
||||
// Naviguer vers le dashboard admin qui chargera la page carte
|
||||
context.go('/admin');
|
||||
},
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: textColor,
|
||||
fontWeight: hasPassages ? FontWeight.w600 : FontWeight.w300,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: textColor.withOpacity(0.5),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: textColor,
|
||||
fontWeight: hasPassages ? FontWeight.w600 : FontWeight.w300,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$count (${percentage.toInt()}%)',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
hasPassages
|
||||
? '$count passages ($progressPercentage% d\'avancement)'
|
||||
: '0 passage',
|
||||
style: TextStyle(
|
||||
fontWeight: hasPassages ? FontWeight.bold : FontWeight.normal,
|
||||
fontSize: 13,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: percentage / 100,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
minHeight: 8,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
const SizedBox(height: 6),
|
||||
// Barre horizontale cumulée avec largeur proportionnelle
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: widthRatio,
|
||||
child: _buildStackedBar(passagesByType, count, sectorId, name),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStackedBar(Map<int, int> passagesByType, int totalCount, int sectorId, String sectorName) {
|
||||
if (totalCount == 0) {
|
||||
// Barre vide pour les secteurs sans passages
|
||||
return Container(
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Ordre des types : 1, 3, 4, 5, 6, 7, 8, 9, puis 2 en dernier
|
||||
final typeOrder = [1, 3, 4, 5, 6, 7, 8, 9, 2];
|
||||
|
||||
return Container(
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.grey[300]!, width: 0.5),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Barre de fond
|
||||
Container(color: Colors.grey[100]),
|
||||
// Barres empilées
|
||||
Row(
|
||||
children: typeOrder.map((typeId) {
|
||||
final count = passagesByType[typeId] ?? 0;
|
||||
if (count == 0) return const SizedBox.shrink();
|
||||
|
||||
final percentage = (count / totalCount) * 100;
|
||||
final typeInfo = AppKeys.typesPassages[typeId];
|
||||
final color = typeInfo != null
|
||||
? Color(typeInfo['couleur2'] as int)
|
||||
: Colors.grey;
|
||||
|
||||
// Vérifier si l'utilisateur est admin pour les clics
|
||||
final bool isAdmin = CurrentUserService.instance.canAccessAdmin;
|
||||
|
||||
return Expanded(
|
||||
flex: count,
|
||||
child: isAdmin
|
||||
? InkWell(
|
||||
onTap: () {
|
||||
// Sauvegarder les filtres dans Hive pour la page historique
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.put('history_selectedSectorId', sectorId);
|
||||
settingsBox.put('history_selectedSectorName', sectorName);
|
||||
settingsBox.put('history_selectedTypeId', typeId);
|
||||
settingsBox.put('adminSelectedPageIndex', 2); // Index de la page historique
|
||||
|
||||
// Naviguer vers le dashboard admin qui chargera la page historique
|
||||
context.go('/admin');
|
||||
},
|
||||
child: Container(
|
||||
color: color,
|
||||
child: Center(
|
||||
child: percentage >= 5 // N'afficher le texte que si >= 5%
|
||||
? Text(
|
||||
'$count (${percentage.toInt()}%)',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(0.5, 0.5),
|
||||
blurRadius: 1.0,
|
||||
color: Colors.black45,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: color,
|
||||
child: Center(
|
||||
child: percentage >= 5 // N'afficher le texte que si >= 5%
|
||||
? Text(
|
||||
'$count (${percentage.toInt()}%)',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(0.5, 0.5),
|
||||
blurRadius: 1.0,
|
||||
color: Colors.black45,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -216,10 +216,12 @@ class _UserFormState extends State<UserForm> {
|
||||
}
|
||||
}
|
||||
|
||||
// Nettoyer une chaîne pour l'username
|
||||
// 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]'), ''); // Garder seulement lettres et chiffres
|
||||
.replaceAll(RegExp(r'[^a-z0-9\s]'), ''); // Garder lettres, chiffres et espaces
|
||||
}
|
||||
|
||||
// Extraire une partie aléatoire d'une chaîne
|
||||
@@ -235,7 +237,7 @@ class _UserFormState extends State<UserForm> {
|
||||
return cleaned.substring(0, length);
|
||||
}
|
||||
|
||||
// Générer un username selon l'algorithme spécifié
|
||||
// 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;
|
||||
@@ -248,22 +250,21 @@ class _UserFormState extends State<UserForm> {
|
||||
final villePart = _extractRandomPart(ville, 2, 4);
|
||||
final nombreAleatoire = 10 + _random.nextInt(990); // 10 à 999
|
||||
|
||||
// Choisir des séparateurs aléatoires (uniquement ceux autorisés: ., _, -)
|
||||
final separateurs = ['', '.', '_', '-'];
|
||||
// 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 10 caractères
|
||||
while (username.length < 10) {
|
||||
// Si trop court, ajouter des chiffres pour atteindre minimum 8 caractères (NIST)
|
||||
while (username.length < 8) {
|
||||
username += _random.nextInt(10).toString();
|
||||
}
|
||||
|
||||
// S'assurer que l'username ne contient que des caractères autorisés (a-z, 0-9, ., -, _)
|
||||
// Normalement déjà le cas avec notre algorithme, mais au cas où
|
||||
username = username.toLowerCase().replaceAll(RegExp(r'[^a-z0-9._-]'), '');
|
||||
// Avec NIST, on n'a plus besoin de nettoyer les caractères spéciaux
|
||||
// Tous les caractères sont acceptés
|
||||
|
||||
return username;
|
||||
}
|
||||
@@ -341,7 +342,7 @@ class _UserFormState extends State<UserForm> {
|
||||
}
|
||||
}
|
||||
|
||||
// Valider le mot de passe selon les règles
|
||||
// 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é
|
||||
@@ -351,85 +352,101 @@ class _UserFormState extends State<UserForm> {
|
||||
return null; // En modification, vide = garder l'ancien
|
||||
}
|
||||
|
||||
// Faire un trim pour retirer les espaces en début/fin
|
||||
final trimmedValue = value.trim();
|
||||
if (trimmedValue.isEmpty) {
|
||||
return "Le mot de passe ne peut pas être vide";
|
||||
// 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 qu'il n'y a pas d'espaces dans le mot de passe
|
||||
if (trimmedValue.contains(' ')) {
|
||||
return "Le mot de passe ne doit pas contenir d'espaces";
|
||||
// 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";
|
||||
}
|
||||
|
||||
// Vérifier la longueur
|
||||
if (trimmedValue.length < 12) {
|
||||
return "Le mot de passe doit contenir au moins 12 caractères";
|
||||
}
|
||||
if (trimmedValue.length > 16) {
|
||||
return "Le mot de passe ne doit pas dépasser 16 caractères";
|
||||
}
|
||||
|
||||
// Vérifier qu'il n'est pas égal au username (après trim des deux)
|
||||
if (trimmedValue == _usernameController.text.trim()) {
|
||||
return "Le mot de passe ne doit pas être identique au nom d'utilisateur";
|
||||
}
|
||||
|
||||
// Vérifier la présence d'au moins une minuscule
|
||||
if (!trimmedValue.contains(RegExp(r'[a-z]'))) {
|
||||
return "Le mot de passe doit contenir au moins une lettre minuscule";
|
||||
}
|
||||
|
||||
// Vérifier la présence d'au moins une majuscule
|
||||
if (!trimmedValue.contains(RegExp(r'[A-Z]'))) {
|
||||
return "Le mot de passe doit contenir au moins une lettre majuscule";
|
||||
}
|
||||
|
||||
// Vérifier la présence d'au moins un chiffre
|
||||
if (!trimmedValue.contains(RegExp(r'[0-9]'))) {
|
||||
return "Le mot de passe doit contenir au moins un chiffre";
|
||||
}
|
||||
|
||||
// Vérifier la présence d'au moins un caractère spécial
|
||||
if (!trimmedValue.contains(RegExp(r'[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]'))) {
|
||||
return "Le mot de passe doit contenir au moins un caractère spécial (!@#\$%^&*()_+-=[]{}|;:,.<>?)";
|
||||
}
|
||||
// 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 aléatoire respectant les règles
|
||||
// Générer un mot de passe selon les normes NIST (phrases de passe recommandées)
|
||||
String _generatePassword() {
|
||||
const String lowercase = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const String uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const String digits = '0123456789';
|
||||
const String special = '!@#\$%^&*()_+-=[]{}|;:,.<>?';
|
||||
// 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'
|
||||
];
|
||||
|
||||
// Longueur aléatoire entre 12 et 16
|
||||
final length = 12 + _random.nextInt(5);
|
||||
final noms = [
|
||||
'Félix', 'Max', 'Luna', 'Bella', 'Charlie', 'Rocky', 'Maya',
|
||||
'Oscar', 'Ruby', 'Leo', 'Emma', 'Jack', 'Sophie', 'Milo', 'Zoé'
|
||||
];
|
||||
|
||||
// S'assurer d'avoir au moins un caractère de chaque type
|
||||
List<String> password = [];
|
||||
password.add(lowercase[_random.nextInt(lowercase.length)]);
|
||||
password.add(uppercase[_random.nextInt(uppercase.length)]);
|
||||
password.add(digits[_random.nextInt(digits.length)]);
|
||||
password.add(special[_random.nextInt(special.length)]);
|
||||
final verbes = [
|
||||
'aime', 'mange', 'court', 'saute', 'danse', 'chante', 'joue',
|
||||
'dort', 'rêve', 'vole', 'nage', 'lit', 'écrit', 'peint', 'cuisine'
|
||||
];
|
||||
|
||||
// Compléter avec des caractères aléatoires
|
||||
const String allChars = lowercase + uppercase + digits + special;
|
||||
for (int i = password.length; i < length; i++) {
|
||||
password.add(allChars[_random.nextInt(allChars.length)]);
|
||||
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';
|
||||
}
|
||||
|
||||
// Mélanger les caractères
|
||||
password.shuffle(_random);
|
||||
// Ajouter éventuellement un caractère spécial à la fin
|
||||
if (_random.nextBool()) {
|
||||
final speciaux = ['!', '?', '.', '...', '♥', '☀', '★', '♪'];
|
||||
phrase += speciaux[_random.nextInt(speciaux.length)];
|
||||
}
|
||||
|
||||
return password.join('');
|
||||
// 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.trim();
|
||||
final password = _passwordController.text; // NIST: ne pas faire de trim, les espaces sont autorisés
|
||||
return password.isNotEmpty ? password : null;
|
||||
}
|
||||
|
||||
@@ -437,7 +454,7 @@ class _UserFormState extends State<UserForm> {
|
||||
UserModel? validateAndGetUser() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
return widget.user?.copyWith(
|
||||
username: _usernameController.text.trim(), // Appliquer trim
|
||||
username: _usernameController.text, // NIST: ne pas faire de trim sur username
|
||||
firstName: _firstNameController.text.trim(),
|
||||
name: _nameController.text.trim(),
|
||||
sectName: _sectNameController.text.trim(),
|
||||
@@ -450,7 +467,7 @@ class _UserFormState extends State<UserForm> {
|
||||
) ??
|
||||
UserModel(
|
||||
id: 0,
|
||||
username: _usernameController.text.trim(), // Appliquer trim
|
||||
username: _usernameController.text, // NIST: ne pas faire de trim sur username
|
||||
firstName: _firstNameController.text.trim(),
|
||||
name: _nameController.text.trim(),
|
||||
sectName: _sectNameController.text.trim(),
|
||||
@@ -729,30 +746,23 @@ class _UserFormState extends State<UserForm> {
|
||||
)
|
||||
: null,
|
||||
helperText: canEditUsername
|
||||
? "Min. 10 caractères (a-z, 0-9, . - _)"
|
||||
? "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";
|
||||
}
|
||||
// Faire un trim pour retirer les espaces en début/fin
|
||||
final trimmedValue = value.trim();
|
||||
if (trimmedValue.isEmpty) {
|
||||
return "Le nom d'utilisateur ne peut pas être vide";
|
||||
// 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 qu'il n'y a pas d'espaces dans l'username
|
||||
if (trimmedValue.contains(' ')) {
|
||||
return "Le nom d'utilisateur ne doit pas contenir d'espaces";
|
||||
}
|
||||
// Vérifier la longueur minimale
|
||||
if (trimmedValue.length < 10) {
|
||||
return "Le nom d'utilisateur doit contenir au moins 10 caractères";
|
||||
}
|
||||
// Vérifier les caractères autorisés (a-z, 0-9, ., -, _)
|
||||
if (!RegExp(r'^[a-z0-9._-]+$').hasMatch(trimmedValue)) {
|
||||
return "Caractères autorisés : lettres minuscules, chiffres, . - _";
|
||||
// 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,
|
||||
@@ -800,7 +810,8 @@ class _UserFormState extends State<UserForm> {
|
||||
),
|
||||
helperText: widget.user?.id != 0
|
||||
? "Laissez vide pour conserver le mot de passe actuel"
|
||||
: "12-16 car. avec min/maj, chiffres et spéciaux (!@#\$%^&*()_+-=[]{}|;:,.<>?)",
|
||||
: "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)",
|
||||
helperMaxLines: 3,
|
||||
validator: _validatePassword,
|
||||
),
|
||||
),
|
||||
@@ -837,30 +848,23 @@ class _UserFormState extends State<UserForm> {
|
||||
)
|
||||
: null,
|
||||
helperText: canEditUsername
|
||||
? "Min. 10 caractères (a-z, 0-9, . - _)"
|
||||
? "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";
|
||||
}
|
||||
// Faire un trim pour retirer les espaces en début/fin
|
||||
final trimmedValue = value.trim();
|
||||
if (trimmedValue.isEmpty) {
|
||||
return "Le nom d'utilisateur ne peut pas être vide";
|
||||
// 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 qu'il n'y a pas d'espaces dans l'username
|
||||
if (trimmedValue.contains(' ')) {
|
||||
return "Le nom d'utilisateur ne doit pas contenir d'espaces";
|
||||
}
|
||||
// Vérifier la longueur minimale
|
||||
if (trimmedValue.length < 10) {
|
||||
return "Le nom d'utilisateur doit contenir au moins 10 caractères";
|
||||
}
|
||||
// Vérifier les caractères autorisés (a-z, 0-9, ., -, _)
|
||||
if (!RegExp(r'^[a-z0-9._-]+$').hasMatch(trimmedValue)) {
|
||||
return "Caractères autorisés : lettres minuscules, chiffres, . - _";
|
||||
// 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,
|
||||
@@ -906,7 +910,8 @@ class _UserFormState extends State<UserForm> {
|
||||
),
|
||||
helperText: widget.user?.id != 0
|
||||
? "Laissez vide pour conserver le mot de passe actuel"
|
||||
: "12-16 car. avec min/maj, chiffres et spéciaux (!@#\$%^&*()_+-=[]{}|;:,.<>?)",
|
||||
: "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),
|
||||
|
||||
Reference in New Issue
Block a user