diff --git a/backend/src/routes/user/dto/create_user.dto.ts b/backend/src/routes/user/dto/create_user.dto.ts index cae620d..ee2f702 100644 --- a/backend/src/routes/user/dto/create_user.dto.ts +++ b/backend/src/routes/user/dto/create_user.dto.ts @@ -36,10 +36,10 @@ export class CreateUserDto { @MaxLength(100) nom: string; - @ApiProperty({ enum: GenreType, required: false, default: GenreType.AUTRE }) + @ApiProperty({ enum: GenreType, required: false }) @IsOptional() @IsEnum(GenreType) - genre?: GenreType = GenreType.AUTRE; + genre?: GenreType; @ApiProperty({ enum: RoleType }) @IsEnum(RoleType) @@ -86,7 +86,7 @@ export class CreateUserDto { @ApiProperty({ default: false }) @IsOptional() @IsBoolean() - consentement_photo?: boolean = false; + consentement_photo?: boolean; @ApiProperty({ required: false }) @IsOptional() @@ -96,7 +96,7 @@ export class CreateUserDto { @ApiProperty({ default: false }) @IsOptional() @IsBoolean() - changement_mdp_obligatoire?: boolean = false; + changement_mdp_obligatoire?: boolean; @ApiProperty({ example: true }) @IsBoolean() diff --git a/backend/src/routes/user/dto/update_gestionnaire.dto.ts b/backend/src/routes/user/dto/update_gestionnaire.dto.ts index f9a2360..4aef50e 100644 --- a/backend/src/routes/user/dto/update_gestionnaire.dto.ts +++ b/backend/src/routes/user/dto/update_gestionnaire.dto.ts @@ -1,4 +1,10 @@ -import { PartialType } from "@nestjs/swagger"; +import { PartialType, ApiProperty } from "@nestjs/swagger"; import { CreateUserDto } from "./create_user.dto"; +import { IsOptional, IsUUID } from "class-validator"; -export class UpdateGestionnaireDto extends PartialType(CreateUserDto) {} \ No newline at end of file +export class UpdateGestionnaireDto extends PartialType(CreateUserDto) { + @ApiProperty({ required: false, description: 'ID du relais de rattachement' }) + @IsOptional() + @IsUUID() + relaisId?: string; +} diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index af65ff1..f5fc93f 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -55,9 +55,9 @@ export class UserController { return this.userService.findOne(id); } - // Modifier un utilisateur (réservé super_admin) + // Modifier un utilisateur (réservé super_admin et admin) @Patch(':id') - @Roles(RoleType.SUPER_ADMIN) + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Mettre à jour un utilisateur' }) @ApiParam({ name: 'id', description: "UUID de l'utilisateur" }) updateUser( diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 08017f7..6b2581e 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -160,6 +160,11 @@ export class UserService { throw new ForbiddenException('Accès réservé aux super admins'); } + // Un admin ne peut pas modifier un super admin + if (currentUser.role === RoleType.ADMINISTRATEUR && user.role === RoleType.SUPER_ADMIN) { + throw new ForbiddenException('Vous ne pouvez pas modifier un super administrateur'); + } + // Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire if ( (user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) && diff --git a/frontend/lib/screens/administrateurs/creation/admin_create.dart b/frontend/lib/screens/administrateurs/creation/admin_create.dart new file mode 100644 index 0000000..e57ac71 --- /dev/null +++ b/frontend/lib/screens/administrateurs/creation/admin_create.dart @@ -0,0 +1,357 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/user_service.dart'; + +class AdminCreateDialog extends StatefulWidget { + final AppUser? initialUser; + + const AdminCreateDialog({ + super.key, + this.initialUser, + }); + + @override + State createState() => _AdminCreateDialogState(); +} + +class _AdminCreateDialogState extends State { + final _formKey = GlobalKey(); + final _nomController = TextEditingController(); + final _prenomController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _telephoneController = TextEditingController(); + + bool _isSubmitting = false; + bool _obscurePassword = true; + bool get _isEditMode => widget.initialUser != null; + + @override + void initState() { + super.initState(); + final user = widget.initialUser; + if (user != null) { + _nomController.text = user.nom ?? ''; + _prenomController.text = user.prenom ?? ''; + _emailController.text = user.email; + _telephoneController.text = user.telephone ?? ''; + // En édition, on ne préremplit jamais le mot de passe. + _passwordController.clear(); + } + } + + @override + void dispose() { + _nomController.dispose(); + _prenomController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _telephoneController.dispose(); + super.dispose(); + } + + String? _required(String? value, String field) { + if (value == null || value.trim().isEmpty) { + return '$field est requis'; + } + return null; + } + + String? _validateEmail(String? value) { + final base = _required(value, 'Email'); + if (base != null) return base; + final email = value!.trim(); + final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email); + if (!ok) return 'Format email invalide'; + return null; + } + + String? _validatePassword(String? value) { + if (_isEditMode && (value == null || value.trim().isEmpty)) { + return null; + } + final base = _required(value, 'Mot de passe'); + if (base != null) return base; + if (value!.trim().length < 6) return 'Minimum 6 caractères'; + return null; + } + + Future _submit() async { + if (_isSubmitting) return; + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isSubmitting = true; + }); + + try { + if (_isEditMode) { + await UserService.updateAdmin( + adminId: widget.initialUser!.id, + nom: _nomController.text.trim(), + prenom: _prenomController.text.trim(), + email: _emailController.text.trim(), + telephone: _telephoneController.text.trim(), + password: _passwordController.text.trim().isEmpty + ? null + : _passwordController.text, + ); + } else { + await UserService.createAdmin( + nom: _nomController.text.trim(), + prenom: _prenomController.text.trim(), + email: _emailController.text.trim(), + password: _passwordController.text, + telephone: _telephoneController.text.trim(), + ); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _isEditMode + ? 'Administrateur modifié avec succès.' + : 'Administrateur créé avec succès.', + ), + ), + ); + Navigator.of(context).pop(true); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e.toString().replaceFirst('Exception: ', ''), + ), + backgroundColor: Colors.red.shade700, + ), + ); + } finally { + if (!mounted) return; + setState(() { + _isSubmitting = false; + }); + } + } + + Future _delete() async { + if (!_isEditMode || _isSubmitting) return; + + final confirmed = await showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + title: const Text('Confirmer la suppression'), + content: Text( + 'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), + child: const Text('Supprimer'), + ), + ], + ); + }, + ); + + if (confirmed != true) return; + + setState(() { + _isSubmitting = true; + }); + try { + await UserService.deleteUser(widget.initialUser!.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Administrateur supprimé.')), + ); + Navigator.of(context).pop(true); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString().replaceFirst('Exception: ', '')), + backgroundColor: Colors.red.shade700, + ), + ); + setState(() { + _isSubmitting = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Row( + children: [ + Expanded( + child: Text( + _isEditMode + ? 'Modifier un administrateur' + : 'Créer un administrateur', + ), + ), + if (_isEditMode) + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Fermer', + onPressed: _isSubmitting + ? null + : () => Navigator.of(context).pop(false), + ), + ], + ), + content: SizedBox( + width: 620, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded(child: _buildNomField()), + const SizedBox(width: 12), + Expanded(child: _buildPrenomField()), + ], + ), + const SizedBox(height: 12), + _buildEmailField(), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: _buildPasswordField()), + const SizedBox(width: 12), + Expanded(child: _buildTelephoneField()), + ], + ), + ], + ), + ), + ), + ), + actions: [ + if (_isEditMode) ...[ + OutlinedButton( + onPressed: _isSubmitting ? null : _delete, + style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), + child: const Text('Supprimer'), + ), + FilledButton.icon( + onPressed: _isSubmitting ? null : _submit, + icon: _isSubmitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.edit), + label: Text(_isSubmitting ? 'Modification...' : 'Modifier'), + ), + ] else ...[ + OutlinedButton( + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + FilledButton.icon( + onPressed: _isSubmitting ? null : _submit, + icon: _isSubmitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.person_add_alt_1), + label: Text(_isSubmitting ? 'Création...' : 'Créer'), + ), + ], + ], + ); + } + + Widget _buildNomField() { + return TextFormField( + controller: _nomController, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Nom', + border: OutlineInputBorder(), + ), + validator: (v) => _required(v, 'Nom'), + ); + } + + Widget _buildPrenomField() { + return TextFormField( + controller: _prenomController, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Prénom', + border: OutlineInputBorder(), + ), + validator: (v) => _required(v, 'Prénom'), + ); + } + + Widget _buildEmailField() { + return TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + validator: _validateEmail, + ); + } + + Widget _buildPasswordField() { + return TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + enableSuggestions: false, + autocorrect: false, + autofillHints: _isEditMode + ? const [] + : const [AutofillHints.newPassword], + decoration: InputDecoration( + labelText: _isEditMode + ? 'Nouveau mot de passe' + : 'Mot de passe', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + ), + ), + ), + validator: _validatePassword, + ); + } + + Widget _buildTelephoneField() { + return TextFormField( + controller: _telephoneController, + keyboardType: TextInputType.phone, + decoration: const InputDecoration( + labelText: 'Téléphone', + border: OutlineInputBorder(), + ), + validator: (v) => _required(v, 'Téléphone'), + ); + } +} diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index d069bca..c80dee4 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -132,6 +132,82 @@ class UserService { return []; } + static Future createAdmin({ + 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); + } + + static Future updateAdmin({ + required String adminId, + required String nom, + required String prenom, + required String email, + required String telephone, + String? password, + }) async { + final body = { + 'nom': nom, + 'prenom': prenom, + 'email': email, + 'telephone': telephone, + }; + + 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 updateGestionnaireRelais({ required String gestionnaireId, required String? relaisId, diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index 666a64e..37040b3 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/admin_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,19 @@ class _AdminManagementWidgetState extends State { } } + Future _openAdminEditDialog(AppUser user) async { + final changed = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AdminCreateDialog(initialUser: user); + }, + ); + if (changed == true) { + await _loadAdmins(); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -80,7 +94,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/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index aa408c4..5b14762 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/screens/administrateurs/creation/admin_create.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; @@ -17,6 +18,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 +152,7 @@ class _AdminUserManagementPanelState extends State { ); case 3: return AdminManagementWidget( + key: ValueKey('admins-$_adminRefreshTick'), searchQuery: _searchController.text, ); default: @@ -176,31 +179,49 @@ 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 GestionnaireCreateDialog(); + }, ); + + 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 AdminCreateDialog(); + }, + ); + + 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 uniquement pour les gestionnaires et les administrateurs.', + ), + ), + ); } }