Files
EM2_ERP/em2rp/lib/views/user_management_page.dart
ElPoyo 4e4573f57b feat: refactor de la gestion des utilisateurs et migration de la logique métier vers les Cloud Functions
Cette mise à jour majeure refactorise entièrement la gestion des utilisateurs pour la faire passer par des Cloud Functions sécurisées et migre une part importante de la logique métier (gestion des événements, maintenances, containers) du client vers le backend.

**Gestion des Utilisateurs (Backend & Frontend):**
- **Nouvelle fonction `createUserWithInvite` :**
    - Crée l'utilisateur dans Firebase Auth avec un mot de passe temporaire.
    - Crée le document utilisateur correspondant dans Firestore.
    - Envoie automatiquement un e-mail de réinitialisation de mot de passe (via l'API REST de Firebase et `axios`) pour que l'utilisateur définisse son propre mot de passe, améliorant la sécurité et l'expérience d'intégration.
- **Refactorisation de `updateUser` et `deleteUser` :**
    - Les anciennes fonctions `onCall` sont remplacées par des fonctions `onRequest` (HTTP) standards, alignées avec le reste de l'API.
    - La logique de suppression gère désormais la suppression dans Auth et Firestore.
- **Réinitialisation de Mot de Passe (UI) :**
    - Ajout d'un bouton "Réinitialiser le mot de passe" sur la carte utilisateur, permettant aux administrateurs d'envoyer un e-mail de réinitialisation à n'importe quel utilisateur.
- **Amélioration de l'UI :**
    - Boîte de dialogue de confirmation améliorée pour la suppression d'un utilisateur.
    - Notifications (Snackbars) pour les opérations de création, suppression et réinitialisation de mot de passe.

**Migration de la Logique Métier vers les Cloud Functions:**
- **Gestion de la Préparation d'Événements :**
    - Migration complète de la logique de validation des étapes (préparation, chargement, déchargement, retour) du client vers de nouvelles Cloud Functions (`validateEquipmentPreparation`, `validateAllLoading`, etc.).
    - Le backend gère désormais la mise à jour des statuts de l'événement (`inProgress`, `completed`) et des équipements (`inUse`, `available`).
    - Le code frontend (`EventPreparationService`) a été simplifié pour appeler ces nouvelles fonctions au lieu d'effectuer des écritures directes sur Firestore.
- **Création de Maintenance :**
    - La fonction `createMaintenance` gère maintenant la mise à jour des équipements associés (`maintenanceIds`) et la création d'alertes (`maintenanceDue`) si une maintenance est prévue prochainement. La logique client a été supprimée.
- **Suppression de Container :**
    - La fonction `deleteContainer` a été améliorée pour nettoyer automatiquement les références (`parentBoxIds`) dans tous les équipements contenus avant de supprimer le container.

**Refactorisation et Corrections (Backend & Frontend) :**
- **Fiabilisation des Appels API (Frontend) :**
    - Le `ApiService` a été renforcé pour convertir de manière plus robuste les données (notamment les `Map` de type `_JsonMap`) en JSON standard avant de les envoyer aux Cloud Functions, évitant ainsi des erreurs de sérialisation.
- **Correction des Références (Backend) :**
    - La fonction `updateUser` convertit correctement les `roleId` (string) en `DocumentReference` Firestore.
    - Sécurisation de la vérification de l'assignation d'un utilisateur à un événement (`workforce`) pour éviter les erreurs sur des références nulles.
- **Dépendance (Backend) :**
    - Ajout de la librairie `axios` pour effectuer des appels à l'API REST de Firebase.
2026-01-14 11:18:49 +01:00

490 lines
18 KiB
Dart

import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/providers/users_provider.dart';
import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/views/widgets/user_management/user_card.dart';
import 'package:em2rp/views/widgets/user_management/edit_user_dialog.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/utils/permission_gate.dart';
import 'package:em2rp/models/role_model.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class UserManagementPage extends StatefulWidget {
const UserManagementPage({super.key});
@override
State<UserManagementPage> createState() => _UserManagementPageState();
}
class _UserManagementPageState extends State<UserManagementPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
Provider.of<UsersProvider>(context, listen: false).fetchUsers();
}
});
}
@override
Widget build(BuildContext context) {
return PermissionGate(
requiredPermissions: const ['view_all_users'],
fallback: const Scaffold(
appBar: CustomAppBar(
title: 'Accès refusé',
),
body: Center(
child: Text(
'Vous n\'avez pas les permissions nécessaires pour accéder à cette page.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
),
),
),
child: Scaffold(
appBar: const CustomAppBar(
title: 'Gestion des utilisateurs',
),
drawer: const MainDrawer(currentPage: '/account_management'),
body: Consumer<UsersProvider>(
builder: (context, usersProvider, child) {
if (usersProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final users = usersProvider.users;
if (users.isEmpty) {
return const Center(child: Text("Aucun utilisateur trouvé"));
}
final width = MediaQuery.of(context).size.width;
int crossAxisCount;
if (width > 1200) {
crossAxisCount = 4;
} else if (width > 800) {
crossAxisCount = 3;
} else if (width > 600) {
crossAxisCount = 2;
} else {
crossAxisCount = 1;
}
return Padding(
padding: const EdgeInsets.all(16),
child: GridView.builder(
itemCount: users.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
mainAxisExtent: width < 600 ? 80 : 180,
),
itemBuilder: (context, i) {
final user = users[i];
return UserCard(
user: user,
onEdit: () => showDialog(
context: context,
builder: (_) => EditUserDialog(user: user)),
onResetPassword: () => _resetPassword(context, user),
onDelete: () => _confirmDeleteUser(context, usersProvider, user),
);
},
),
);
},
),
floatingActionButton: FloatingActionButton(
backgroundColor: AppColors.rouge,
child: const Icon(Icons.add, color: AppColors.blanc),
onPressed: () => _showCreateUserDialog(context),
),
),
);
}
void _showCreateUserDialog(BuildContext context) {
final firstNameController = TextEditingController();
final lastNameController = TextEditingController();
final emailController = TextEditingController();
final phoneController = TextEditingController();
String? selectedRoleId;
List<RoleModel> availableRoles = [];
bool isLoadingRoles = true;
Future<void> loadRoles() async {
try {
final dataService = DataService(FirebaseFunctionsApiService());
final rolesData = await dataService.getRoles();
availableRoles = rolesData
.map((data) => RoleModel.fromMap(data, data['id'] as String))
.toList();
selectedRoleId =
availableRoles.isNotEmpty ? availableRoles.first.id : null;
isLoadingRoles = false;
} catch (e) {
isLoadingRoles = false;
}
}
InputDecoration buildInputDecoration(String label, IconData icon) {
return InputDecoration(
labelText: label,
prefixIcon: Icon(icon, color: AppColors.rouge),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.rouge, width: 2),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
);
}
showDialog(
context: context,
builder: (context) => FutureBuilder(
future: loadRoles(),
builder: (context, snapshot) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
width: 400,
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.person_add, color: AppColors.rouge),
const SizedBox(width: 12),
Text(
'Nouvel utilisateur',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.noir,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 24),
SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: firstNameController,
decoration: buildInputDecoration(
'Prénom', Icons.person_outline),
),
const SizedBox(height: 16),
TextField(
controller: lastNameController,
decoration: buildInputDecoration('Nom', Icons.person),
),
const SizedBox(height: 16),
TextField(
controller: emailController,
decoration: buildInputDecoration(
'Email', Icons.email_outlined),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: phoneController,
decoration: buildInputDecoration(
'Téléphone', Icons.phone_outlined),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 16),
isLoadingRoles
? const CircularProgressIndicator()
: DropdownButtonFormField<String>(
initialValue: selectedRoleId,
decoration: buildInputDecoration('Rôle',
Icons.admin_panel_settings_outlined),
items: availableRoles.map((role) {
return DropdownMenuItem<String>(
value: role.id,
child: Text(role.name),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
selectedRoleId = newValue;
}
},
),
],
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
),
child: const Text(
'Annuler',
style: TextStyle(color: AppColors.gris),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () async {
if (emailController.text.isEmpty ||
firstNameController.text.isEmpty ||
lastNameController.text.isEmpty ||
selectedRoleId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Veuillez remplir tous les champs obligatoires'),
backgroundColor: Colors.red,
),
);
return;
}
try {
await Provider.of<UsersProvider>(context,
listen: false)
.createUserWithEmailInvite(
email: emailController.text,
firstName: firstNameController.text,
lastName: lastNameController.text,
phoneNumber: phoneController.text,
roleId: selectedRoleId!,
);
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Utilisateur créé avec succès. Email de réinitialisation envoyé à ${emailController.text}',
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Erreur lors de la création: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Inviter',
style: TextStyle(color: AppColors.blanc),
),
),
],
),
],
),
),
);
},
),
);
}
/// Réinitialise le mot de passe d'un utilisateur
Future<void> _resetPassword(BuildContext context, UserModel user) async {
try {
await Provider.of<UsersProvider>(context, listen: false)
.resetPassword(user.email);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Email de réinitialisation envoyé à ${user.email}',
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Erreur lors de l\'envoi: ${e.toString()}',
),
backgroundColor: Colors.red,
),
);
}
}
}
/// Affiche une confirmation avant de supprimer un utilisateur
Future<void> _confirmDeleteUser(
BuildContext context,
UsersProvider usersProvider,
UserModel user,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
const Icon(Icons.warning, color: Colors.orange),
const SizedBox(width: 12),
Expanded(
child: Text(
'Confirmer la suppression',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Êtes-vous sûr de vouloir supprimer cet utilisateur ?',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.person, size: 20, color: AppColors.noir),
const SizedBox(width: 8),
Expanded(
child: Text(
'${user.firstName} ${user.lastName}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.email, size: 20, color: AppColors.gris),
const SizedBox(width: 8),
Expanded(
child: Text(
user.email,
style: TextStyle(color: Colors.grey[700]),
),
),
],
),
],
),
),
const SizedBox(height: 16),
Text(
'Cette action est irréversible. L\'utilisateur sera supprimé et désattribué de tous les événements liés',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.red[700],
fontStyle: FontStyle.italic,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
child: const Text(
'Annuler',
style: TextStyle(color: AppColors.gris),
),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Supprimer',
style: TextStyle(color: Colors.white),
),
),
],
),
);
if (confirmed == true && context.mounted) {
try {
await usersProvider.deleteUser(user.uid);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Utilisateur ${user.firstName} ${user.lastName} supprimé avec succès',
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Erreur lors de la suppression: ${e.toString()}',
),
backgroundColor: Colors.red,
),
);
}
}
}
}
}