feat: Ajout de la gestion des utilisateurs et optimisation du chargement des données
Cette mise à jour introduit la gestion complète des utilisateurs (création, mise à jour, suppression) via des Cloud Functions et optimise de manière significative le chargement des données dans toute l'application.
**Features :**
- **Gestion des utilisateurs (Backend & Frontend) :**
- Ajout des Cloud Functions `getUser`, `updateUser` et `deleteUser` pour gérer les utilisateurs de manière sécurisée, en respectant les permissions des rôles.
- L'authentification passe désormais par `onCall` pour plus de sécurité.
- **Optimisation du chargement des données :**
- Introduction de nouvelles Cloud Functions `getEquipmentsByIds` et `getContainersByIds` pour récupérer uniquement les documents nécessaires, réduisant ainsi la charge sur le client et Firestore.
- Les fournisseurs (`EquipmentProvider`, `ContainerProvider`) ont été refactorisés pour utiliser un chargement à la demande (`ensureLoaded`) et mettre en cache les données récupérées.
- Les écrans de détails et de préparation d'événements n'utilisent plus de `Stream` globaux, mais chargent les équipements et boites spécifiques via ces nouvelles fonctions, améliorant considérablement les performances.
**Refactorisation et Améliorations :**
- **Backend (Cloud Functions) :**
- Le service de vérification de disponibilité (`checkEquipmentAvailability`) est désormais une Cloud Function, déplaçant la logique métier côté serveur.
- La récupération des données (utilisateurs, événements, alertes) a été centralisée derrière des Cloud Functions, remplaçant les appels directs à Firestore depuis le client.
- Amélioration de la sérialisation des données (timestamps, références) dans les réponses des fonctions.
- **Frontend (Flutter) :**
- `LocalUserProvider` charge désormais les informations de l'utilisateur connecté via la fonction `getCurrentUser`, incluant son rôle et ses permissions en un seul appel.
- `AlertProvider` utilise des fonctions pour charger et manipuler les alertes, abandonnant le `Stream` Firestore.
- `EventAvailabilityService` utilise maintenant la Cloud Function `checkEquipmentAvailability` au lieu d'une logique client complexe.
- Correction de la gestion des références de rôles (`roles/ADMIN`) et des `DocumentReference` pour les utilisateurs dans l'ensemble de l'application.
- Le service d'export ICS (`IcsExportService`) a été simplifié, partant du principe que les données nécessaires (utilisateurs, options) sont déjà chargées dans l'application.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
* Architecture backend sécurisée avec authentification et permissions
|
||||
*/
|
||||
|
||||
const { onRequest } = require("firebase-functions/v2/https");
|
||||
const { onRequest, onCall } = require("firebase-functions/v2/https");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const admin = require('firebase-admin');
|
||||
const { Storage } = require('@google-cloud/storage');
|
||||
@@ -257,6 +257,54 @@ exports.getEquipment = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
}
|
||||
}));
|
||||
|
||||
// Récupérer plusieurs équipements par leurs IDs
|
||||
exports.getEquipmentsByIds = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasViewAccess = await auth.hasPermission(decodedToken.uid, 'view_equipment');
|
||||
const hasManageAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
|
||||
|
||||
if (!hasViewAccess && !hasManageAccess) {
|
||||
res.status(403).json({ error: 'Forbidden: Requires view_equipment permission' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { equipmentIds } = req.body.data || {};
|
||||
|
||||
if (!equipmentIds || !Array.isArray(equipmentIds) || equipmentIds.length === 0) {
|
||||
res.status(400).json({ error: 'equipmentIds array is required and must not be empty' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Limiter à 100 équipements max par requête
|
||||
if (equipmentIds.length > 100) {
|
||||
res.status(400).json({ error: 'Maximum 100 equipment IDs per request' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer tous les documents en parallèle
|
||||
const promises = equipmentIds.map(id => db.collection('equipments').doc(id).get());
|
||||
const docs = await Promise.all(promises);
|
||||
|
||||
const equipments = [];
|
||||
for (const doc of docs) {
|
||||
if (doc.exists) {
|
||||
let data = { id: doc.id, ...doc.data() };
|
||||
data = helpers.serializeTimestamps(data);
|
||||
data = helpers.serializeReferences(data);
|
||||
// Masquer les prix si pas de permission manage_equipment
|
||||
data = helpers.maskSensitiveFields(data, hasManageAccess);
|
||||
equipments.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({ equipments });
|
||||
} catch (error) {
|
||||
logger.error("Error getting equipments by IDs:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
// CONTAINERS - CRUD
|
||||
// ============================================================================
|
||||
@@ -366,20 +414,19 @@ exports.getContainersByEquipment = onRequest(httpOptions, withCors(async (req, r
|
||||
return;
|
||||
}
|
||||
|
||||
const { equipmentId } = req.body.data || req.query;
|
||||
const { equipmentId } = req.body.data || {};
|
||||
|
||||
if (!equipmentId) {
|
||||
res.status(400).json({ error: 'equipmentId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer tous les containers qui contiennent cet équipement
|
||||
const containersSnapshot = await db.collection('containers')
|
||||
const snapshot = await db.collection('containers')
|
||||
.where('equipmentIds', 'array-contains', equipmentId)
|
||||
.get();
|
||||
|
||||
const containers = [];
|
||||
containersSnapshot.forEach(doc => {
|
||||
snapshot.forEach(doc => {
|
||||
let data = { id: doc.id, ...doc.data() };
|
||||
data = helpers.serializeTimestamps(data);
|
||||
data = helpers.serializeReferences(data);
|
||||
@@ -393,6 +440,53 @@ exports.getContainersByEquipment = onRequest(httpOptions, withCors(async (req, r
|
||||
}
|
||||
}));
|
||||
|
||||
// Récupérer plusieurs containers par leurs IDs
|
||||
exports.getContainersByIds = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasViewAccess = await auth.hasPermission(decodedToken.uid, 'view_equipment');
|
||||
const hasManageAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
|
||||
|
||||
if (!hasViewAccess && !hasManageAccess) {
|
||||
res.status(403).json({ error: 'Forbidden: Requires view_equipment permission' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { containerIds } = req.body.data || {};
|
||||
|
||||
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
|
||||
res.status(400).json({ error: 'containerIds array is required and must not be empty' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Limiter à 100 conteneurs max par requête
|
||||
if (containerIds.length > 100) {
|
||||
res.status(400).json({ error: 'Maximum 100 container IDs per request' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer tous les documents en parallèle
|
||||
const promises = containerIds.map(id => db.collection('containers').doc(id).get());
|
||||
const docs = await Promise.all(promises);
|
||||
|
||||
const containers = [];
|
||||
for (const doc of docs) {
|
||||
if (doc.exists) {
|
||||
let data = { id: doc.id, ...doc.data() };
|
||||
data = helpers.serializeTimestamps(data);
|
||||
data = helpers.serializeReferences(data);
|
||||
containers.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({ containers });
|
||||
} catch (error) {
|
||||
logger.error("Error getting containers by IDs:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// EVENTS - CRUD
|
||||
// ============================================================================
|
||||
@@ -1345,11 +1439,14 @@ exports.getUsers = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = userDoc.data();
|
||||
let userData = userDoc.data();
|
||||
userData = helpers.serializeTimestamps(userData);
|
||||
userData = helpers.serializeReferences(userData);
|
||||
|
||||
res.status(200).json({
|
||||
users: [{
|
||||
id: userDoc.id,
|
||||
...helpers.serializeTimestamps(userData)
|
||||
...userData
|
||||
}]
|
||||
});
|
||||
return;
|
||||
@@ -1357,10 +1454,15 @@ exports.getUsers = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
|
||||
// Admin : tous les utilisateurs
|
||||
const snapshot = await db.collection('users').get();
|
||||
const users = snapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(doc.data())
|
||||
}));
|
||||
const users = snapshot.docs.map(doc => {
|
||||
let data = doc.data();
|
||||
data = helpers.serializeTimestamps(data);
|
||||
data = helpers.serializeReferences(data);
|
||||
return {
|
||||
id: doc.id,
|
||||
...data
|
||||
};
|
||||
});
|
||||
|
||||
res.status(200).json({ users });
|
||||
} catch (error) {
|
||||
@@ -1370,46 +1472,200 @@ exports.getUsers = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
// CONTAINERS - Récupération par équipement
|
||||
// USER - Récupération individuelle
|
||||
// ============================================================================
|
||||
|
||||
|
||||
/**
|
||||
* Récupère un utilisateur spécifique par son ID
|
||||
* Tout utilisateur authentifié peut accéder aux données publiques
|
||||
*/
|
||||
exports.getUser = onCall(async (request) => {
|
||||
try {
|
||||
await authenticateUser(request);
|
||||
const db = getFirestore();
|
||||
|
||||
const { userId } = request.data;
|
||||
if (!userId) {
|
||||
throw new Error("userId is required");
|
||||
}
|
||||
|
||||
const userDoc = await db.collection("users").doc(userId).get();
|
||||
if (!userDoc.exists) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const user = userDoc.data();
|
||||
|
||||
// Données publiques accessibles à tous
|
||||
const userData = {
|
||||
id: userDoc.id,
|
||||
uid: user.uid || userDoc.id,
|
||||
email: user.email || "",
|
||||
firstName: user.firstName || "",
|
||||
lastName: user.lastName || "",
|
||||
phoneNumber: user.phoneNumber || "",
|
||||
profilePhotoUrl: user.profilePhotoUrl || "",
|
||||
};
|
||||
|
||||
// Inclure le rôle si disponible
|
||||
if (user.role) {
|
||||
const roleDoc = await user.role.get();
|
||||
if (roleDoc.exists) {
|
||||
userData.role = {
|
||||
id: roleDoc.id,
|
||||
...roleDoc.data(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { user: userData };
|
||||
} catch (error) {
|
||||
logger.error("Error fetching user:", error);
|
||||
throw new Error(error.message || "Failed to fetch user");
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// USER MANAGEMENT - Delete & Update
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Récupère tous les containers contenant un équipement spécifique
|
||||
* Accessible à tous les utilisateurs authentifiés
|
||||
* Supprime un utilisateur (Auth + Firestore)
|
||||
* Permissions: 'delete_user' OU propriétaire
|
||||
*/
|
||||
exports.getContainersByEquipment = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
exports.deleteUser = onCall(async (request) => {
|
||||
const { auth, data } = request;
|
||||
|
||||
if (!auth) {
|
||||
throw new Error("Unauthorized: Authentication required");
|
||||
}
|
||||
|
||||
const { userId } = data;
|
||||
if (!userId) {
|
||||
throw new Error("userId is required");
|
||||
}
|
||||
|
||||
try {
|
||||
// Vérifier l'authentification
|
||||
const user = await auth.authenticateUser(req);
|
||||
// Vérifier les permissions
|
||||
const callerDoc = await db.collection("users").doc(auth.uid).get();
|
||||
const callerData = callerDoc.data();
|
||||
|
||||
const equipmentId = req.body.data?.equipmentId;
|
||||
|
||||
if (!equipmentId) {
|
||||
res.status(400).json({ error: 'equipmentId is required' });
|
||||
return;
|
||||
if (!callerData) {
|
||||
throw new Error("Caller user not found");
|
||||
}
|
||||
|
||||
logger.info(`Fetching containers for equipment: ${equipmentId}`);
|
||||
// Vérifier si l'utilisateur a la permission delete_user
|
||||
let canDelete = false;
|
||||
if (callerData.role) {
|
||||
const roleDoc = await callerData.role.get();
|
||||
const roleData = roleDoc.data();
|
||||
canDelete = roleData?.permissions?.includes("delete_user") || false;
|
||||
}
|
||||
|
||||
// Requête pour trouver tous les containers contenant cet équipement
|
||||
const containersSnapshot = await db.collection('containers')
|
||||
.where('equipmentIds', 'array-contains', equipmentId)
|
||||
.get();
|
||||
// Ou si c'est le propriétaire (mais on ne peut pas se supprimer soi-même)
|
||||
if (userId === auth.uid) {
|
||||
throw new Error("Cannot delete your own account");
|
||||
}
|
||||
|
||||
const containers = containersSnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(doc.data())
|
||||
}));
|
||||
if (!canDelete) {
|
||||
throw new Error("Unauthorized: Missing delete_user permission");
|
||||
}
|
||||
|
||||
logger.info(`Found ${containers.length} container(s) for equipment ${equipmentId}`);
|
||||
// Supprimer de Firebase Auth
|
||||
try {
|
||||
await admin.auth().deleteUser(userId);
|
||||
} catch (authError) {
|
||||
logger.warn(`Could not delete user from Auth: ${authError.message}`);
|
||||
// Continuer même si Auth échoue (l'utilisateur peut ne plus exister dans Auth)
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
containers,
|
||||
count: containers.length
|
||||
});
|
||||
// Supprimer de Firestore
|
||||
await db.collection("users").doc(userId).delete();
|
||||
|
||||
logger.info(`User ${userId} deleted by ${auth.uid}`);
|
||||
return { success: true, message: "User deleted successfully" };
|
||||
} catch (error) {
|
||||
logger.error("Error fetching containers by equipment:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
logger.error("Error deleting user:", error);
|
||||
throw new Error(error.message || "Failed to delete user");
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* Met à jour un utilisateur
|
||||
* Permissions: 'edit_user' OU propriétaire (modifications limitées)
|
||||
*/
|
||||
exports.updateUser = onCall(async (request) => {
|
||||
const { auth, data } = request;
|
||||
|
||||
if (!auth) {
|
||||
throw new Error("Unauthorized: Authentication required");
|
||||
}
|
||||
|
||||
const { userId, userData } = data;
|
||||
if (!userId || !userData) {
|
||||
throw new Error("userId and userData are required");
|
||||
}
|
||||
|
||||
try {
|
||||
// Vérifier les permissions
|
||||
const callerDoc = await db.collection("users").doc(auth.uid).get();
|
||||
const callerData = callerDoc.data();
|
||||
|
||||
if (!callerData) {
|
||||
throw new Error("Caller user not found");
|
||||
}
|
||||
|
||||
let canEditAll = false;
|
||||
if (callerData.role) {
|
||||
const roleDoc = await callerData.role.get();
|
||||
const roleData = roleDoc.data();
|
||||
canEditAll = roleData?.permissions?.includes("edit_user") || false;
|
||||
}
|
||||
|
||||
const isOwner = userId === auth.uid;
|
||||
|
||||
// Si pas de permission edit_user et pas propriétaire, refuser
|
||||
if (!canEditAll && !isOwner) {
|
||||
throw new Error("Unauthorized: Missing edit_user permission");
|
||||
}
|
||||
|
||||
// Préparer les données à mettre à jour
|
||||
const updateData = {
|
||||
firstName: userData.firstName,
|
||||
lastName: userData.lastName,
|
||||
email: userData.email,
|
||||
phoneNumber: userData.phoneNumber || "",
|
||||
};
|
||||
|
||||
// Seuls ceux avec edit_user peuvent changer le rôle
|
||||
if (userData.role) {
|
||||
if (!canEditAll) {
|
||||
throw new Error("Unauthorized: Cannot change role without edit_user permission");
|
||||
}
|
||||
// Créer la référence au rôle
|
||||
updateData.role = db.collection("roles").doc(userData.role);
|
||||
}
|
||||
|
||||
// Mettre à jour Firestore
|
||||
await db.collection("users").doc(userId).update(updateData);
|
||||
|
||||
// Mettre à jour Firebase Auth si email a changé (seulement avec edit_user)
|
||||
if (userData.email && canEditAll) {
|
||||
try {
|
||||
await admin.auth().updateUser(userId, {
|
||||
email: userData.email,
|
||||
});
|
||||
} catch (authError) {
|
||||
logger.warn(`Could not update email in Auth: ${authError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`User ${userId} updated by ${auth.uid}`);
|
||||
return { success: true, message: "User updated successfully" };
|
||||
} catch (error) {
|
||||
logger.error("Error updating user:", error);
|
||||
throw new Error(error.message || "Failed to update user");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user