import React, { useCallback, useEffect, useRef } from "react"; import { collection, deleteDoc, doc, DocumentSnapshot, Firestore, getFirestore, onSnapshot, orderBy, query, setDoc } from "@firebase/firestore"; import { FirebaseApp } from "@firebase/app"; import { FireCMSBackend, FireCMSCloudUserWithRoles } from "../types"; import { CMSType, PermissionsBuilder, Role, User } from "@firecms/core"; import { ProjectsApi } from "../api/projects"; import { resolveUserRolePermissions, UserManagement } from "@firecms/user_management"; type UserWithRoleIds = User & { roles: string[], firebase_uid: string }; interface UserManagementParams { backendFirebaseApp?: FirebaseApp; projectId: string; projectsApi: ProjectsApi; fireCMSBackend: FireCMSBackend } export type CloudUserManagement = UserManagement & { updateUserFields: (saas_uid: string, fields: Partial) => Promise; }; export function useBuildCloudUserManagement({ backendFirebaseApp, projectId, projectsApi, fireCMSBackend }: UserManagementParams): CloudUserManagement { const configPath = projectId ? `projects/${projectId}` : undefined; const firestoreRef = useRef(undefined); const [rolesLoading, setRolesLoading] = React.useState(true); const [usersLoading, setUsersLoading] = React.useState(true); const [roles, setRoles] = React.useState([]); const [usersWithRoleIds, setUsersWithRoleIds] = React.useState([]); const users = usersWithRoleIds.map(u => ({ ...u, uid: u.firebase_uid, saas_uid: u.uid, roles: roles.filter(r => u.roles.includes(r.id)) } satisfies FireCMSCloudUserWithRoles)); const [rolesError, setRolesError] = React.useState(); const [usersError, setUsersError] = React.useState(); useEffect(() => { if (!backendFirebaseApp) return; firestoreRef.current = getFirestore(backendFirebaseApp); }, [backendFirebaseApp]); useEffect(() => { const firestore = firestoreRef.current; if (!firestore || !configPath) return; return onSnapshot(collection(firestore, configPath, "roles"), { next: (snapshot) => { setRolesError(undefined); try { const newRoles = docsToRoles(snapshot.docs); setRoles(newRoles); } catch (e) { // console.error(e); setRolesError(e as Error); } setRolesLoading(false); }, error: (e) => { setRolesError(e); setRolesLoading(false); } } ); }, [configPath]); useEffect(() => { const firestore = firestoreRef.current; if (!firestore || !configPath) return; return onSnapshot(query(collection(firestore, configPath, "users"), orderBy("created_on", "asc")), { next: (snapshot) => { setUsersError(undefined); try { const newUsers = docsToUsers(snapshot.docs); setUsersWithRoleIds(newUsers); } catch (e) { // console.error(e); setUsersError(e as Error); } setUsersLoading(false); }, error: (e) => { setUsersError(e); setUsersLoading(false); } } ); }, [configPath]); const saveUser = useCallback(async (user: FireCMSCloudUserWithRoles): Promise => { const firestore = firestoreRef.current; if (!firestore || !configPath) throw Error("useFirestoreConfigurationPersistence Firestore not initialised"); console.debug("Persisting", user); const { saas_uid, ...userData } = user; if (saas_uid) { return projectsApi.updateUser(projectId, saas_uid, user); } else { return projectsApi.createNewUser(projectId, user); } }, [configPath, projectId]); /** * Update specific fields of a user directly in Firestore. * This bypasses the API for simple field updates like photoURL. */ const updateUserFields = useCallback(async ( saas_uid: string, fields: Partial ): Promise => { const firestore = firestoreRef.current; if (!firestore || !configPath) throw Error("Firestore not initialised"); console.debug("Updating user fields directly in Firestore", { saas_uid, fields }); const ref = doc(firestore, configPath, "users", saas_uid); return setDoc(ref, { ...fields, updated_on: new Date() }, { merge: true }); }, [configPath]); const saveRole = useCallback((role: Role): Promise => { const firestore = firestoreRef.current; if (!firestore || !configPath) throw Error("useFirestoreConfigurationPersistence Firestore not initialised"); console.debug("Persisting", role); const { id, ...roleData } = role; const ref = doc(firestore, configPath, "roles", id); return setDoc(ref, roleData, { merge: true }); }, [configPath]); const removeUser = useCallback(async (user: FireCMSCloudUserWithRoles): Promise => { const firestore = firestoreRef.current; if (!firestore || !configPath) throw Error("useFirestoreConfigurationPersistence Firestore not initialised"); console.debug("Deleting", user); const { saas_uid } = user; return projectsApi.deleteUser(projectId, saas_uid); }, [configPath]); const deleteRole = useCallback((role: Role): Promise => { const firestore = firestoreRef.current; if (!firestore || !configPath) throw Error("useFirestoreConfigurationPersistence Firestore not initialised"); console.debug("Deleting", role); const { id } = role; const ref = doc(firestore, configPath, "roles", id); return deleteDoc(ref); }, [configPath]); const loggedInUser = users.find((u) => u.email?.toLowerCase() === fireCMSBackend.user?.email?.toLowerCase()); const collectionPermissions: PermissionsBuilder = useCallback(({ collection, }) => resolveUserRolePermissions({ collection, user: loggedInUser ?? null }), [loggedInUser?.uid]); const userIds = users.map(u => u.uid); const defineRolesFor: ((user: User) => Role[] | undefined) = useCallback((user) => { if (!users) throw Error("Users not loaded"); const mgmtUser = users.find(u => u.email?.toLowerCase() === user?.email?.toLowerCase()); return mgmtUser?.roles; }, [userIds]); const isAdmin = loggedInUser?.roles.some(r => r.id === "admin"); const getUser = useCallback((uid: string): User | null => { if (!users) throw Error("Users not loaded"); const mgmtUser = users.find(u => u.uid === uid); return mgmtUser ?? null; }, [userIds]); return { allowDefaultRolesCreation: false, includeCollectionConfigPermissions: true, loading: rolesLoading || usersLoading, roles, users, saveUser, updateUserFields, saveRole, defineRolesFor, deleteUser: removeUser, deleteRole, isAdmin, collectionPermissions, getUser } } const docsToUsers = (docs: DocumentSnapshot[]): UserWithRoleIds[] => { return docs.map((doc) => ({ ...doc.data(), uid: doc.id, // Document ID is the saas_uid (used for API calls) created_on: doc.data()?.created_on?.toDate(), updated_on: doc.data()?.updated_on?.toDate() } as unknown as UserWithRoleIds)); } const docsToRoles = (docs: DocumentSnapshot[]): Role[] => { return docs.map((doc) => ({ id: doc.id, ...doc.data() } as Role)); }