From d8572e7fd6a0690a6713160116c8607649c506f0 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 24 Feb 2026 17:25:15 +0100 Subject: [PATCH 1/4] =?UTF-8?q?feat(#96):=20finaliser=20la=20modale=20admi?= =?UTF-8?q?n/gestionnaire=20et=20les=20r=C3=A8gles=20d=E2=80=99=C3=A9ditio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unifie la modale utilisateur pour création/édition admin et gestionnaire, fiabilise la saisie/normalisation (téléphone, nom/prénom) et corrige la mise à jour backend pour accepter le rattachement relais sans erreur 400. Co-authored-by: Cursor --- .../user/dto/update_gestionnaire.dto.ts | 4 +- .../creation/gestionnaires_create.dart | 247 ++++++++++++++---- frontend/lib/services/user_service.dart | 86 +++++- .../admin/admin_management_widget.dart | 20 +- .../admin/gestionnaire_management_widget.dart | 2 +- .../widgets/admin/user_management_panel.dart | 63 +++-- 6 files changed, 352 insertions(+), 70 deletions(-) diff --git a/backend/src/routes/user/dto/update_gestionnaire.dto.ts b/backend/src/routes/user/dto/update_gestionnaire.dto.ts index f9a2360..ab035db 100644 --- a/backend/src/routes/user/dto/update_gestionnaire.dto.ts +++ b/backend/src/routes/user/dto/update_gestionnaire.dto.ts @@ -1,4 +1,4 @@ import { PartialType } from "@nestjs/swagger"; -import { CreateUserDto } from "./create_user.dto"; +import { CreateGestionnaireDto } from "./create_gestionnaire.dto"; -export class UpdateGestionnaireDto extends PartialType(CreateUserDto) {} \ No newline at end of file +export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {} \ No newline at end of file diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index e658063..d248dce 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -1,29 +1,35 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; -class GestionnaireCreateDialog extends StatefulWidget { +class AdminUserFormDialog extends StatefulWidget { final AppUser? initialUser; + final bool withRelais; + final bool adminMode; - const GestionnaireCreateDialog({ + const AdminUserFormDialog({ super.key, this.initialUser, + this.withRelais = true, + this.adminMode = false, }); @override - State createState() => - _GestionnaireCreateDialogState(); + State createState() => _AdminUserFormDialogState(); } -class _GestionnaireCreateDialogState extends State { +class _AdminUserFormDialogState extends State { final _formKey = GlobalKey(); final _nomController = TextEditingController(); final _prenomController = TextEditingController(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); final _telephoneController = TextEditingController(); + final _passwordToggleFocusNode = + FocusNode(skipTraversal: true, canRequestFocus: false); bool _isSubmitting = false; bool _obscurePassword = true; @@ -40,7 +46,7 @@ class _GestionnaireCreateDialogState extends State { _nomController.text = user.nom ?? ''; _prenomController.text = user.prenom ?? ''; _emailController.text = user.email; - _telephoneController.text = user.telephone ?? ''; + _telephoneController.text = _formatPhoneForDisplay(user.telephone ?? ''); // En édition, on ne préremplit jamais le mot de passe. _passwordController.clear(); final initialRelaisId = user.relaisId?.trim(); @@ -49,7 +55,11 @@ class _GestionnaireCreateDialogState extends State { ? null : initialRelaisId; } - _loadRelais(); + if (widget.withRelais) { + _loadRelais(); + } else { + _isLoadingRelais = false; + } } @override @@ -59,6 +69,7 @@ class _GestionnaireCreateDialogState extends State { _emailController.dispose(); _passwordController.dispose(); _telephoneController.dispose(); + _passwordToggleFocusNode.dispose(); super.dispose(); } @@ -122,6 +133,66 @@ class _GestionnaireCreateDialogState extends State { return null; } + String? _validatePhone(String? value) { + if (_isEditMode && (value == null || value.trim().isEmpty)) { + return null; + } + final base = _required(value, 'Téléphone'); + if (base != null) return base; + final digits = _normalizePhone(value!); + if (digits.length != 10) { + return 'Le téléphone doit contenir 10 chiffres'; + } + if (!digits.startsWith('0')) { + return 'Le téléphone doit commencer par 0'; + } + return null; + } + + String _normalizePhone(String raw) { + return raw.replaceAll(RegExp(r'\D'), ''); + } + + String _formatPhoneForDisplay(String raw) { + final normalized = _normalizePhone(raw); + final digits = + normalized.length > 10 ? normalized.substring(0, 10) : normalized; + final buffer = StringBuffer(); + for (var i = 0; i < digits.length; i++) { + if (i > 0 && i.isEven) buffer.write(' '); + buffer.write(digits[i]); + } + return buffer.toString(); + } + + String _toTitleCase(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return trimmed; + final words = trimmed.split(RegExp(r'\s+')); + final normalizedWords = words.map(_capitalizeComposedWord).toList(); + return normalizedWords.join(' '); + } + + String _capitalizeComposedWord(String word) { + if (word.isEmpty) return word; + final lower = word.toLowerCase(); + final separators = {"-", "'", "’"}; + final buffer = StringBuffer(); + var capitalizeNext = true; + + for (var i = 0; i < lower.length; i++) { + final char = lower[i]; + if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) { + buffer.write(char.toUpperCase()); + capitalizeNext = false; + } else { + buffer.write(char); + capitalizeNext = separators.contains(char); + } + } + return buffer.toString(); + } + Future _submit() async { if (_isSubmitting) return; if (!_formKey.currentState!.validate()) return; @@ -131,35 +202,85 @@ class _GestionnaireCreateDialogState extends State { }); try { + final normalizedNom = _toTitleCase(_nomController.text); + final normalizedPrenom = _toTitleCase(_prenomController.text); + final normalizedPhone = _normalizePhone(_telephoneController.text); + final passwordProvided = _passwordController.text.trim().isNotEmpty; + if (_isEditMode) { - await UserService.updateGestionnaire( - gestionnaireId: widget.initialUser!.id, - nom: _nomController.text.trim(), - prenom: _prenomController.text.trim(), - email: _emailController.text.trim(), - telephone: _telephoneController.text.trim(), - relaisId: _selectedRelaisId, - password: _passwordController.text.trim().isEmpty - ? null - : _passwordController.text, - ); + if (widget.adminMode) { + await UserService.updateAdministrateur( + adminId: widget.initialUser!.id, + nom: normalizedNom, + prenom: normalizedPrenom, + email: _emailController.text.trim(), + telephone: normalizedPhone.isEmpty + ? _normalizePhone(widget.initialUser!.telephone ?? '') + : normalizedPhone, + password: passwordProvided ? _passwordController.text : null, + ); + } else { + final currentUser = widget.initialUser!; + final initialNom = _toTitleCase(currentUser.nom ?? ''); + final initialPrenom = _toTitleCase(currentUser.prenom ?? ''); + final initialEmail = currentUser.email.trim(); + final initialPhone = _normalizePhone(currentUser.telephone ?? ''); + + final onlyRelaisChanged = + normalizedNom == initialNom && + normalizedPrenom == initialPrenom && + _emailController.text.trim() == initialEmail && + normalizedPhone == initialPhone && + !passwordProvided; + + if (onlyRelaisChanged) { + await UserService.updateGestionnaireRelais( + gestionnaireId: currentUser.id, + relaisId: _selectedRelaisId, + ); + } else { + await UserService.updateGestionnaire( + gestionnaireId: currentUser.id, + nom: normalizedNom, + prenom: normalizedPrenom, + email: _emailController.text.trim(), + telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone, + relaisId: _selectedRelaisId, + password: passwordProvided ? _passwordController.text : null, + ); + } + } } else { - await UserService.createGestionnaire( - nom: _nomController.text.trim(), - prenom: _prenomController.text.trim(), - email: _emailController.text.trim(), - password: _passwordController.text, - telephone: _telephoneController.text.trim(), - relaisId: _selectedRelaisId, - ); + if (widget.adminMode) { + await UserService.createAdministrateur( + nom: normalizedNom, + prenom: normalizedPrenom, + email: _emailController.text.trim(), + password: _passwordController.text, + telephone: _normalizePhone(_telephoneController.text), + ); + } else { + await UserService.createGestionnaire( + nom: normalizedNom, + prenom: normalizedPrenom, + email: _emailController.text.trim(), + password: _passwordController.text, + telephone: _normalizePhone(_telephoneController.text), + relaisId: _selectedRelaisId, + ); + } } if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( _isEditMode - ? 'Gestionnaire modifié avec succès.' - : 'Gestionnaire créé avec succès.', + ? (widget.adminMode + ? 'Administrateur modifié avec succès.' + : 'Gestionnaire modifié avec succès.') + : (widget.adminMode + ? 'Administrateur créé avec succès.' + : 'Gestionnaire créé avec succès.'), ), ), ); @@ -242,8 +363,12 @@ class _GestionnaireCreateDialogState extends State { Expanded( child: Text( _isEditMode - ? 'Modifier un gestionnaire' - : 'Créer un gestionnaire', + ? (widget.adminMode + ? 'Modifier un administrateur' + : 'Modifier un gestionnaire') + : (widget.adminMode + ? 'Créer un administrateur' + : 'Créer un gestionnaire'), ), ), if (_isEditMode) @@ -266,9 +391,9 @@ class _GestionnaireCreateDialogState extends State { children: [ Row( children: [ - Expanded(child: _buildNomField()), - const SizedBox(width: 12), Expanded(child: _buildPrenomField()), + const SizedBox(width: 12), + Expanded(child: _buildNomField()), ], ), const SizedBox(height: 12), @@ -281,8 +406,10 @@ class _GestionnaireCreateDialogState extends State { Expanded(child: _buildTelephoneField()), ], ), - const SizedBox(height: 12), - _buildRelaisField(), + if (widget.withRelais) ...[ + const SizedBox(height: 12), + _buildRelaisField(), + ], ], ), ), @@ -378,14 +505,17 @@ class _GestionnaireCreateDialogState extends State { ? 'Nouveau mot de passe' : 'Mot de passe', border: const OutlineInputBorder(), - suffixIcon: IconButton( - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - icon: Icon( - _obscurePassword ? Icons.visibility_off : Icons.visibility, + suffixIcon: ExcludeFocus( + child: IconButton( + focusNode: _passwordToggleFocusNode, + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + ), ), ), ), @@ -397,11 +527,16 @@ class _GestionnaireCreateDialogState extends State { return TextFormField( controller: _telephoneController, keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + _FrenchPhoneNumberFormatter(), + ], decoration: const InputDecoration( - labelText: 'Téléphone', + labelText: 'Téléphone (ex: 06 12 34 56 78)', border: OutlineInputBorder(), ), - validator: (v) => _required(v, 'Téléphone'), + validator: _validatePhone, ); } @@ -448,4 +583,28 @@ class _GestionnaireCreateDialogState extends State { ], ); } +} + +class _FrenchPhoneNumberFormatter extends TextInputFormatter { + const _FrenchPhoneNumberFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + final normalized = digits.length > 10 ? digits.substring(0, 10) : digits; + final buffer = StringBuffer(); + for (var i = 0; i < normalized.length; i++) { + if (i > 0 && i.isEven) buffer.write(' '); + buffer.write(normalized[i]); + } + final formatted = buffer.toString(); + + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } } \ No newline at end of file diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index d069bca..84659c8 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -75,6 +75,41 @@ class UserService { return AppUser.fromJson(data); } + static Future createAdministrateur({ + required String nom, + required String prenom, + required String email, + required String password, + required String telephone, + }) async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'), + headers: await _headers(), + body: jsonEncode({ + 'nom': nom, + 'prenom': prenom, + 'email': email, + 'password': password, + 'telephone': telephone, + }), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final message = decoded['message']; + if (message is List && message.isNotEmpty) { + throw Exception(message.join(' - ')); + } + throw Exception(_toStr(message) ?? 'Erreur création administrateur'); + } + throw Exception('Erreur création administrateur'); + } + + final data = jsonDecode(response.body) as Map; + return AppUser.fromJson(data); + } + // Récupérer la liste des parents static Future> getParents() async { final response = await http.get( @@ -156,7 +191,7 @@ class UserService { required String nom, required String prenom, required String email, - required String telephone, + String? telephone, required String? relaisId, String? password, }) async { @@ -164,10 +199,13 @@ class UserService { 'nom': nom, 'prenom': prenom, 'email': email, - 'telephone': telephone, 'relaisId': relaisId, }; + if (telephone != null && telephone.trim().isNotEmpty) { + body['telephone'] = telephone.trim(); + } + if (password != null && password.trim().isNotEmpty) { body['password'] = password.trim(); } @@ -194,6 +232,50 @@ class UserService { return AppUser.fromJson(data); } + static Future updateAdministrateur({ + required String adminId, + required String nom, + required String prenom, + required String email, + String? telephone, + String? password, + }) async { + final body = { + 'nom': nom, + 'prenom': prenom, + 'email': email, + }; + + if (telephone != null && telephone.trim().isNotEmpty) { + body['telephone'] = telephone.trim(); + } + + if (password != null && password.trim().isNotEmpty) { + body['password'] = password.trim(); + } + + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'), + headers: await _headers(), + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final message = decoded['message']; + if (message is List && message.isNotEmpty) { + throw Exception(message.join(' - ')); + } + throw Exception(_toStr(message) ?? 'Erreur modification administrateur'); + } + throw Exception('Erreur modification administrateur'); + } + + final data = jsonDecode(response.body) as Map; + return AppUser.fromJson(data); + } + static Future deleteUser(String userId) async { final response = await http.delete( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'), diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index 666a64e..a8e2db5 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -51,6 +52,23 @@ class _AdminManagementWidgetState extends State { } } + Future _openAdminEditDialog(AppUser user) async { + final changed = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AdminUserFormDialog( + initialUser: user, + adminMode: true, + withRelais: false, + ); + }, + ); + if (changed == true) { + await _loadAdmins(); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -80,7 +98,7 @@ class _AdminManagementWidgetState extends State { icon: const Icon(Icons.edit), tooltip: 'Modifier', onPressed: () { - // TODO: Modifier admin + _openAdminEditDialog(user); }, ), ], diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index 58b78e6..4ea3ead 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -59,7 +59,7 @@ class _GestionnaireManagementWidgetState context: context, barrierDismissible: false, builder: (dialogContext) { - return GestionnaireCreateDialog(initialUser: user); + return AdminUserFormDialog(initialUser: user); }, ); if (changed == true) { diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index aa408c4..63bc921 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -17,6 +17,7 @@ class AdminUserManagementPanel extends StatefulWidget { class _AdminUserManagementPanelState extends State { int _subIndex = 0; int _gestionnaireRefreshTick = 0; + int _adminRefreshTick = 0; final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; @@ -150,6 +151,7 @@ class _AdminUserManagementPanelState extends State { ); case 3: return AdminManagementWidget( + key: ValueKey('admins-$_adminRefreshTick'), searchQuery: _searchController.text, ); default: @@ -176,31 +178,52 @@ class _AdminUserManagementPanelState extends State { } Future _handleAddPressed() async { - if (_subIndex != 0) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'La création est disponible uniquement pour les gestionnaires.', - ), - ), + if (_subIndex == 0) { + final created = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return const AdminUserFormDialog(); + }, ); + + if (!mounted) return; + if (created == true) { + setState(() { + _gestionnaireRefreshTick++; + }); + } return; } - final created = await showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - return const GestionnaireCreateDialog(); - }, - ); + if (_subIndex == 3) { + final created = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return const AdminUserFormDialog( + adminMode: true, + withRelais: false, + ); + }, + ); + + if (!mounted) return; + if (created == true) { + setState(() { + _adminRefreshTick++; + }); + } + return; + } if (!mounted) return; - if (created == true) { - setState(() { - _gestionnaireRefreshTick++; - }); - } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'La création est disponible pour les gestionnaires et administrateurs.', + ), + ), + ); } } From e2ebc6a0a1667e26914e5e4c15a9c223dc710f93 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 24 Feb 2026 21:48:11 +0100 Subject: [PATCH 2/4] =?UTF-8?q?feat(#96):=20diff=C3=A9rencier=20la=20consu?= =?UTF-8?q?ltation=20admin=20et=20le=20mode=20=C3=A9dition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Affiche une identité visuelle dédiée pour les super admins et adapte l’action par ligne (oeil en lecture seule, crayon en édition) avec modale strictement read-only quand l’utilisateur n’a pas les droits. Co-authored-by: Cursor --- .../creation/gestionnaires_create.dart | 80 ++++++++++++------- .../admin/admin_management_widget.dart | 48 ++++++++++- .../widgets/admin/common/admin_user_card.dart | 15 +++- 3 files changed, 108 insertions(+), 35 deletions(-) diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index d248dce..9417eae 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -9,12 +9,14 @@ class AdminUserFormDialog extends StatefulWidget { final AppUser? initialUser; final bool withRelais; final bool adminMode; + final bool readOnly; const AdminUserFormDialog({ super.key, this.initialUser, this.withRelais = true, this.adminMode = false, + this.readOnly = false, }); @override @@ -194,6 +196,7 @@ class _AdminUserFormDialogState extends State { } Future _submit() async { + if (widget.readOnly) return; if (_isSubmitting) return; if (!_formKey.currentState!.validate()) return; @@ -304,6 +307,7 @@ class _AdminUserFormDialogState extends State { } Future _delete() async { + if (widget.readOnly) return; if (!_isEditMode || _isSubmitting) return; final confirmed = await showDialog( @@ -363,15 +367,19 @@ class _AdminUserFormDialogState extends State { Expanded( child: Text( _isEditMode - ? (widget.adminMode - ? 'Modifier un administrateur' - : 'Modifier un gestionnaire') + ? (widget.readOnly + ? (widget.adminMode + ? 'Consulter un administrateur' + : 'Consulter un gestionnaire') + : (widget.adminMode + ? 'Modifier un administrateur' + : 'Modifier un gestionnaire')) : (widget.adminMode ? 'Créer un administrateur' : 'Créer un gestionnaire'), ), ), - if (_isEditMode) + if (_isEditMode && !widget.readOnly) IconButton( icon: const Icon(Icons.close), tooltip: 'Fermer', @@ -416,7 +424,12 @@ class _AdminUserFormDialogState extends State { ), ), actions: [ - if (_isEditMode) ...[ + if (widget.readOnly) ...[ + FilledButton( + onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false), + child: const Text('Fermer'), + ), + ] else if (_isEditMode) ...[ OutlinedButton( onPressed: _isSubmitting ? null : _delete, style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), @@ -458,42 +471,46 @@ class _AdminUserFormDialogState extends State { Widget _buildNomField() { return TextFormField( controller: _nomController, + readOnly: widget.readOnly, textCapitalization: TextCapitalization.words, decoration: const InputDecoration( labelText: 'Nom', border: OutlineInputBorder(), ), - validator: (v) => _required(v, 'Nom'), + validator: widget.readOnly ? null : (v) => _required(v, 'Nom'), ); } Widget _buildPrenomField() { return TextFormField( controller: _prenomController, + readOnly: widget.readOnly, textCapitalization: TextCapitalization.words, decoration: const InputDecoration( labelText: 'Prénom', border: OutlineInputBorder(), ), - validator: (v) => _required(v, 'Prénom'), + validator: widget.readOnly ? null : (v) => _required(v, 'Prénom'), ); } Widget _buildEmailField() { return TextFormField( controller: _emailController, + readOnly: widget.readOnly, keyboardType: TextInputType.emailAddress, decoration: const InputDecoration( labelText: 'Email', border: OutlineInputBorder(), ), - validator: _validateEmail, + validator: widget.readOnly ? null : _validateEmail, ); } Widget _buildPasswordField() { return TextFormField( controller: _passwordController, + readOnly: widget.readOnly, obscureText: _obscurePassword, enableSuggestions: false, autocorrect: false, @@ -505,38 +522,43 @@ class _AdminUserFormDialogState extends State { ? 'Nouveau mot de passe' : 'Mot de passe', border: const OutlineInputBorder(), - suffixIcon: ExcludeFocus( - child: IconButton( - focusNode: _passwordToggleFocusNode, - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - icon: Icon( - _obscurePassword ? Icons.visibility_off : Icons.visibility, - ), - ), - ), + suffixIcon: widget.readOnly + ? null + : ExcludeFocus( + child: IconButton( + focusNode: _passwordToggleFocusNode, + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + ), + ), + ), ), - validator: _validatePassword, + validator: widget.readOnly ? null : _validatePassword, ); } Widget _buildTelephoneField() { return TextFormField( controller: _telephoneController, + readOnly: widget.readOnly, keyboardType: TextInputType.phone, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - _FrenchPhoneNumberFormatter(), - ], + inputFormatters: widget.readOnly + ? null + : [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + _FrenchPhoneNumberFormatter(), + ], decoration: const InputDecoration( labelText: 'Téléphone (ex: 06 12 34 56 78)', border: OutlineInputBorder(), ), - validator: _validatePhone, + validator: widget.readOnly ? null : _validatePhone, ); } @@ -568,7 +590,7 @@ class _AdminUserFormDialogState extends State { ), ), ], - onChanged: _isLoadingRelais + onChanged: (_isLoadingRelais || widget.readOnly) ? null : (value) { setState(() { diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index a8e2db5..2c73bdb 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -21,10 +22,12 @@ class _AdminManagementWidgetState extends State { bool _isLoading = false; String? _error; List _admins = []; + String? _currentUserRole; @override void initState() { super.initState(); + _loadCurrentUserRole(); _loadAdmins(); } @@ -52,7 +55,31 @@ class _AdminManagementWidgetState extends State { } } + Future _loadCurrentUserRole() async { + final cached = await AuthService.getCurrentUser(); + if (!mounted) return; + if (cached != null) { + setState(() { + _currentUserRole = cached.role.toLowerCase(); + }); + return; + } + final refreshed = await AuthService.refreshCurrentUser(); + if (!mounted || refreshed == null) return; + setState(() { + _currentUserRole = refreshed.role.toLowerCase(); + }); + } + + bool _isSuperAdmin(AppUser user) => user.role.toLowerCase() == 'super_admin'; + + bool _canEditAdmin(AppUser target) { + if (!_isSuperAdmin(target)) return true; + return _currentUserRole == 'super_admin'; + } + Future _openAdminEditDialog(AppUser user) async { + final canEdit = _canEditAdmin(user); final changed = await showDialog( context: context, barrierDismissible: false, @@ -61,10 +88,11 @@ class _AdminManagementWidgetState extends State { initialUser: user, adminMode: true, withRelais: false, + readOnly: !canEdit, ); }, ); - if (changed == true) { + if (changed == true && canEdit) { await _loadAdmins(); } } @@ -86,6 +114,8 @@ class _AdminManagementWidgetState extends State { itemCount: filteredAdmins.length, itemBuilder: (context, index) { final user = filteredAdmins[index]; + final isSuperAdmin = _isSuperAdmin(user); + final canEdit = _canEditAdmin(user); return AdminUserCard( title: user.fullName, subtitleLines: [ @@ -93,10 +123,22 @@ class _AdminManagementWidgetState extends State { 'Rôle : ${user.role}', ], avatarUrl: user.photoUrl, + borderColor: isSuperAdmin + ? const Color(0xFF8E6AC8) + : Colors.grey.shade300, + backgroundColor: isSuperAdmin + ? const Color(0xFFF4EEFF) + : Colors.white, + titleColor: isSuperAdmin ? const Color(0xFF5D2F99) : null, + infoColor: isSuperAdmin + ? const Color(0xFF6D4EA1) + : Colors.black54, actions: [ IconButton( - icon: const Icon(Icons.edit), - tooltip: 'Modifier', + icon: Icon( + canEdit ? Icons.edit_outlined : Icons.visibility_outlined, + ), + tooltip: canEdit ? 'Modifier' : 'Consulter', onPressed: () { _openAdminEditDialog(user); }, diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 914e218..92d4237 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -6,6 +6,10 @@ class AdminUserCard extends StatefulWidget { final String? avatarUrl; final IconData fallbackIcon; final List actions; + final Color? borderColor; + final Color? backgroundColor; + final Color? titleColor; + final Color? infoColor; const AdminUserCard({ super.key, @@ -14,6 +18,10 @@ class AdminUserCard extends StatefulWidget { this.avatarUrl, this.fallbackIcon = Icons.person, this.actions = const [], + this.borderColor, + this.backgroundColor, + this.titleColor, + this.infoColor, }); @override @@ -43,9 +51,10 @@ class _AdminUserCardState extends State { child: Card( margin: const EdgeInsets.only(bottom: 12), elevation: 0, + color: widget.backgroundColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), - side: BorderSide(color: Colors.grey.shade300), + side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), @@ -76,7 +85,7 @@ class _AdminUserCardState extends State { style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 14, - ), + ).copyWith(color: widget.titleColor), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -88,7 +97,7 @@ class _AdminUserCardState extends State { style: const TextStyle( color: Colors.black54, fontSize: 12, - ), + ).copyWith(color: widget.infoColor), maxLines: 1, overflow: TextOverflow.ellipsis, ), From 2645cf1cd63ab85cdcd91ad9f806305e03087e87 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 24 Feb 2026 22:05:17 +0100 Subject: [PATCH 3/4] =?UTF-8?q?fix(#96):=20prot=C3=A9ger=20le=20super=20ad?= =?UTF-8?q?min=20en=20=C3=A9dition=20et=20suppression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empêche la suppression d'un super administrateur et fige son identité (nom/prénom) côté API, avec alignement de la modale frontend pour masquer la suppression et verrouiller ces champs. Co-authored-by: Cursor --- backend/src/routes/user/user.service.ts | 16 +++++++++ .../creation/gestionnaires_create.dart | 34 +++++++++++++------ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 08017f7..77365c0 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -155,6 +155,16 @@ export class UserService { async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise { const user = await this.findOne(id); + // Le super administrateur conserve une identité figée. + if ( + user.role === RoleType.SUPER_ADMIN && + (dto.nom !== undefined || dto.prenom !== undefined) + ) { + throw new ForbiddenException( + 'Le nom et le prénom du super administrateur ne peuvent pas être modifiés', + ); + } + // Interdire changement de rôle si pas super admin if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('Accès réservé aux super admins'); @@ -251,6 +261,12 @@ export class UserService { if (currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('Accès réservé aux super admins'); } + const user = await this.findOne(id); + if (user.role === RoleType.SUPER_ADMIN) { + throw new ForbiddenException( + 'Le super administrateur ne peut pas être supprimé', + ); + } const result = await this.usersRepository.delete(id); if (result.affected === 0) { throw new NotFoundException('Utilisateur introuvable'); diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 9417eae..9a79fbb 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -39,6 +39,10 @@ class _AdminUserFormDialogState extends State { List _relais = []; String? _selectedRelaisId; bool get _isEditMode => widget.initialUser != null; + bool get _isSuperAdminTarget => + widget.initialUser?.role.toLowerCase() == 'super_admin'; + bool get _isLockedAdminIdentity => + _isEditMode && widget.adminMode && _isSuperAdminTarget; @override void initState() { @@ -212,10 +216,12 @@ class _AdminUserFormDialogState extends State { if (_isEditMode) { if (widget.adminMode) { + final lockedNom = _toTitleCase(widget.initialUser!.nom ?? ''); + final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? ''); await UserService.updateAdministrateur( adminId: widget.initialUser!.id, - nom: normalizedNom, - prenom: normalizedPrenom, + nom: _isLockedAdminIdentity ? lockedNom : normalizedNom, + prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom, email: _emailController.text.trim(), telephone: normalizedPhone.isEmpty ? _normalizePhone(widget.initialUser!.telephone ?? '') @@ -308,6 +314,7 @@ class _AdminUserFormDialogState extends State { Future _delete() async { if (widget.readOnly) return; + if (_isSuperAdminTarget) return; if (!_isEditMode || _isSubmitting) return; final confirmed = await showDialog( @@ -430,11 +437,12 @@ class _AdminUserFormDialogState extends State { child: const Text('Fermer'), ), ] else if (_isEditMode) ...[ - OutlinedButton( - onPressed: _isSubmitting ? null : _delete, - style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), - child: const Text('Supprimer'), - ), + if (!_isSuperAdminTarget) + OutlinedButton( + onPressed: _isSubmitting ? null : _delete, + style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), + child: const Text('Supprimer'), + ), FilledButton.icon( onPressed: _isSubmitting ? null : _submit, icon: _isSubmitting @@ -471,26 +479,30 @@ class _AdminUserFormDialogState extends State { Widget _buildNomField() { return TextFormField( controller: _nomController, - readOnly: widget.readOnly, + readOnly: widget.readOnly || _isLockedAdminIdentity, textCapitalization: TextCapitalization.words, decoration: const InputDecoration( labelText: 'Nom', border: OutlineInputBorder(), ), - validator: widget.readOnly ? null : (v) => _required(v, 'Nom'), + validator: (widget.readOnly || _isLockedAdminIdentity) + ? null + : (v) => _required(v, 'Nom'), ); } Widget _buildPrenomField() { return TextFormField( controller: _prenomController, - readOnly: widget.readOnly, + readOnly: widget.readOnly || _isLockedAdminIdentity, textCapitalization: TextCapitalization.words, decoration: const InputDecoration( labelText: 'Prénom', border: OutlineInputBorder(), ), - validator: widget.readOnly ? null : (v) => _required(v, 'Prénom'), + validator: (widget.readOnly || _isLockedAdminIdentity) + ? null + : (v) => _required(v, 'Prénom'), ); } From d14550a1cfcfc74628e9390753653f5cfbdb9d5d Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 24 Feb 2026 22:31:23 +0100 Subject: [PATCH 4/4] =?UTF-8?q?feat(#96):=20harmoniser=20les=20icones=20de?= =?UTF-8?q?=20r=C3=B4les=20en=20liste=20et=20modale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uniformise l'identité visuelle des rôles (admin, super admin, gestionnaire, parent) avec icônes dédiées dans les listes et la modale, et affiche le téléphone dans la ligne admin en retirant le rôle redondant. Co-authored-by: Cursor --- .../creation/gestionnaires_create.dart | 62 ++++++++++++++++--- .../admin/admin_management_widget.dart | 5 +- .../admin/gestionnaire_management_widget.dart | 1 + .../admin/parent_managmant_widget.dart | 1 + 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 9a79fbb..3a2ce05 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -43,6 +43,46 @@ class _AdminUserFormDialogState extends State { widget.initialUser?.role.toLowerCase() == 'super_admin'; bool get _isLockedAdminIdentity => _isEditMode && widget.adminMode && _isSuperAdminTarget; + String get _targetRoleKey { + if (widget.initialUser != null) { + return widget.initialUser!.role.toLowerCase(); + } + return widget.adminMode ? 'administrateur' : 'gestionnaire'; + } + + String get _targetRoleLabel { + switch (_targetRoleKey) { + case 'super_admin': + return 'Super administrateur'; + case 'administrateur': + return 'Administrateur'; + case 'gestionnaire': + return 'Gestionnaire'; + case 'assistante_maternelle': + return 'Assistante maternelle'; + case 'parent': + return 'Parent'; + default: + return 'Utilisateur'; + } + } + + IconData get _targetRoleIcon { + switch (_targetRoleKey) { + case 'super_admin': + return Icons.verified_user_outlined; + case 'administrateur': + return Icons.admin_panel_settings_outlined; + case 'gestionnaire': + return Icons.assignment_ind_outlined; + case 'assistante_maternelle': + return Icons.child_care_outlined; + case 'parent': + return Icons.supervisor_account_outlined; + default: + return Icons.person_outline; + } + } @override void initState() { @@ -371,19 +411,23 @@ class _AdminUserFormDialogState extends State { return AlertDialog( title: Row( children: [ + CircleAvatar( + radius: 16, + backgroundColor: const Color(0xFFEDE5FA), + child: Icon( + _targetRoleIcon, + size: 20, + color: const Color(0xFF6B3FA0), + ), + ), + const SizedBox(width: 8), Expanded( child: Text( _isEditMode ? (widget.readOnly - ? (widget.adminMode - ? 'Consulter un administrateur' - : 'Consulter un gestionnaire') - : (widget.adminMode - ? 'Modifier un administrateur' - : 'Modifier un gestionnaire')) - : (widget.adminMode - ? 'Créer un administrateur' - : 'Créer un gestionnaire'), + ? 'Consulter un "$_targetRoleLabel"' + : 'Modifier un "$_targetRoleLabel"') + : 'Créer un "$_targetRoleLabel"', ), ), if (_isEditMode && !widget.readOnly) diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index 2c73bdb..be51365 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -118,9 +118,12 @@ class _AdminManagementWidgetState extends State { final canEdit = _canEditAdmin(user); return AdminUserCard( title: user.fullName, + fallbackIcon: isSuperAdmin + ? Icons.verified_user_outlined + : Icons.manage_accounts_outlined, subtitleLines: [ user.email, - 'Rôle : ${user.role}', + 'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseigné'}', ], avatarUrl: user.photoUrl, borderColor: isSuperAdmin diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index 4ea3ead..2692831 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -86,6 +86,7 @@ class _GestionnaireManagementWidgetState final user = filteredGestionnaires[index]; return AdminUserCard( title: user.fullName, + fallbackIcon: Icons.assignment_ind_outlined, avatarUrl: user.photoUrl, subtitleLines: [ user.email, diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index cfa8637..5b8e20a 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -75,6 +75,7 @@ class _ParentManagementWidgetState extends State { final parent = filteredParents[index]; return AdminUserCard( title: parent.user.fullName, + fallbackIcon: Icons.supervisor_account_outlined, avatarUrl: parent.user.photoUrl, subtitleLines: [ parent.user.email,