import React, { useCallback, useEffect, useMemo, useState } from "react"; import { FirebaseApp } from "firebase/app"; import { BrowserRouter, Route } from "react-router-dom"; import { AppBar, CircularProgressCenter, DefaultAppBarProps, Drawer, ErrorView, FireCMS, FireCMSPlugin, ModeController, ModeControllerProvider, NavigationRoutes, PermissionsBuilder, Scaffold, SideDialogs, SnackbarProvider, useBrowserTitleAndIcon, useBuildLocalConfigurationPersistence, useBuildModeController, useBuildNavigationController, User, FireCMSi18nProvider } from "@firecms/core"; import { buildCollectionInference, useFirestoreCollectionsConfigController } from "@firecms/collection_editor_firebase"; import { CollectionEditorPermissionsBuilder, CollectionsConfigController, MissingReferenceWidget, PersistedCollection, useCollectionEditorPlugin, buildCollectionGenerationCallback } from "@firecms/collection_editor"; import { useDataEnhancementPlugin } from "@firecms/data_enhancement"; import { CloudUserManagement, FireCMSBackEndProvider, ProjectConfig, ProjectConfigProvider, useBuildCloudUserManagement, useBuildFireCMSBackend, useBuildProjectConfig, useDelegatedLogin, useFirestoreExplorerMode } from "./hooks"; import { FireCMSCloudAppProps } from "./FireCMSCloudAppProps"; import { ApiError, FireCMSAppConfig, FireCMSBackend, FireCMSCloudUser, FireCMSCloudUserWithRoles } from "./types"; import { RESERVED_GROUPS, resolveCollectionConfigPermissions } from "./utils"; import { CloudErrorView, FireCMSCloudDrawer, FireCMSCloudLoginView, ProjectSettings } from "./components"; import { FireCMSCloudHomePage } from "./components/FireCMSCloudHomePage"; import { buildFireCMSSearchController, FirebaseAuthController, getFirestoreDataInPath, useAppCheck, useFirebaseAuthController, useFirebaseStorageSource, useFirestoreDelegate, useInitialiseFirebase } from "@firecms/firebase"; import { useExportPlugin } from "@firecms/data_export"; import { useImportPlugin } from "@firecms/data_import"; import { Button, CenteredView, ErrorIcon, Typography } from "@firecms/ui"; import { useSaasPlugin } from "./hooks/useSaasPlugin"; import { resolveUserRolePermissions, RolesView, UserManagement, UserManagementProvider, UsersView } from "@firecms/user_management"; import { DataTalkRoutes, useDataTalkPlugin } from "@firecms/datatalk"; import { useEntityHistoryPlugin } from "@firecms/entity_history"; import { useRootCollectionSuggestions } from "./hooks/useRootCollectionSuggestions"; import { useFirebaseAdminPlugin, FirestoreExplorer, AdminApiProvider, buildAdminApi } from "@firecms/firebase_admin"; /** * This is the default implementation of a FireCMS app using the Firebase services * as a backend. * You can use this component as a full app, by specifying collections and * entity collections. * * This component is in charge of initialising Firebase, with the given * configuration object. * * @param props * @group Firebase */ export function FireCMSCloudApp({ projectId, appConfig, backendApiHost = "https://api.firecms.co", // TODO onAnalyticsEvent, basePath, baseCollectionPath }: FireCMSCloudAppProps) { const modeController = useBuildModeController(); const { firebaseApp: backendFirebaseApp, firebaseConfigLoading: backendConfigLoading, configError, firebaseConfigError: backendFirebaseConfigError } = useInitialiseFirebase({ name: "firecms-backend", fromUrl: backendApiHost + "/config" }); const fireCMSBackend = useBuildFireCMSBackend({ backendApiHost, backendFirebaseApp }); let component; if (backendConfigLoading || !backendFirebaseApp) { component = ; } else if (backendFirebaseConfigError) { component = } else if (configError) { component = } else if (fireCMSBackend.authLoading) { component = ; } else if (!fireCMSBackend.user) { component = } else { component = } return {component} ; } export type FireCMSClientProps = { fireCMSBackend: FireCMSBackend, projectId: string; appConfig?: FireCMSAppConfig; modeController: ModeController; /** * A component that gets rendered on the upper side of the main toolbar. */ FireCMSAppBarComponent?: React.ComponentType>; basePath?: string; baseCollectionPath?: string; onAnalyticsEvent?: (event: string, data?: object) => void; }; function FullLoadingView(props: { projectId: string, projectConfig?: ProjectConfig, FireCMSAppBarComponent?: React.ComponentType, text?: string }) { return {props.FireCMSAppBarComponent && } ; } export const FireCMSClient = function FireCMSClient({ projectId, fireCMSBackend, ...props }: FireCMSClientProps) { const projectConfig = useBuildProjectConfig({ projectId, backendFirebaseApp: fireCMSBackend.backendFirebaseApp }); const userManagement = useBuildCloudUserManagement({ backendFirebaseApp: fireCMSBackend.backendFirebaseApp, projectId, projectsApi: fireCMSBackend.projectsApi, fireCMSBackend }); if (userManagement.loading || (!projectConfig.clientFirebaseConfig && !projectConfig.configError)) { return ; } return ; }; function ErrorDelegatingLoginView({ configError, onLogout, fireCMSBackend, onFixed }: { configError: Error | ApiError, onLogout: () => void, fireCMSBackend: FireCMSBackend, onFixed?: () => void }) { const errorBody = "code" in configError ? : <> {configError.message} This error may be caused when a user has been deleted from the client project. Make sure a user exists in the client project with the same email as the one trying to log in. If the problem persists, reach us at hello@firecms.co , or in our Discord channel. ; return
Error logging in
{errorBody}
If you are experiencing issues logging in, feel free to reach us at hello@firecms.co
; } function NoAccessErrorView(props: { projectId: string, configError: Error, onLogout: () => void }) { return
Error accessing project
{props.configError.message} This error may be caused when trying to access with a user that is not registered in the project {props.projectId}. You can ask the project owner to add you to the project.
; } export function FireCMSClientWithController({ projectConfig, userManagement, projectId, fireCMSBackend, appConfig, customizationLoading, ...props }: FireCMSClientProps & { logo?: string; userManagement: CloudUserManagement; projectConfig: ProjectConfig; projectId: string; customizationLoading: boolean; }) { const [notValidUser, setNotValidUser] = useState(); const { firebaseApp: clientFirebaseApp, firebaseConfigLoading, configError: firebaseConfigError } = useInitialiseFirebase({ onFirebaseInit: appConfig?.onFirebaseInit, firebaseConfig: projectConfig.clientFirebaseConfig }); const appCheckResult = useAppCheck({ firebaseApp: clientFirebaseApp, options: appConfig?.appCheck ?? projectConfig.appCheck }); const authController: FirebaseAuthController = useFirebaseAuthController({ firebaseApp: clientFirebaseApp, onSignOut: fireCMSBackend.signOut, defineRolesFor: userManagement.defineRolesFor }); const fireCMSUser = useMemo(() => { if (userManagement.loading || authController.authLoading) return; const user = authController.user; if (!user) return; return userManagement.users.find((fireCMSUser) => fireCMSUser.email?.toLowerCase() === user?.email?.toLowerCase()); }, [authController.authLoading, authController.user, userManagement.loading, userManagement.users]); const { delegatedLoginLoading, delegatedLoginError, checkLogin } = useDelegatedLogin({ projectsApi: fireCMSBackend.projectsApi, firebaseApp: clientFirebaseApp, projectId, onUserChanged: (user) => { authController.setUser(user ?? null); authController.setUserRoles(fireCMSUser?.roles ?? []); }, onAnalyticsEvent: props.onAnalyticsEvent }); const permissions: PermissionsBuilder = useCallback(({ collection, user, }) => { return resolveUserRolePermissions({ collection, user }); }, []); const configController = useFirestoreCollectionsConfigController({ firebaseApp: fireCMSBackend.backendFirebaseApp, generalConfigPath: `projects/${projectId}`, configPath: `projects/${projectId}/collections`, permissions, propertyConfigs: appConfig?.propertyConfigs }); // Sync user profile data (photoURL, displayName) from Google sign-in if missing or changed const profileSyncRef = React.useRef<{ photoURL?: string | null, displayName?: string | null }>({}); useEffect(() => { const backendUser = fireCMSBackend.user; if (!backendUser || !fireCMSUser || userManagement.loading) return; if (!fireCMSUser.saas_uid) return; const backendPhotoURL = backendUser.photoURL; const backendDisplayName = backendUser.displayName; const storedPhotoURL = fireCMSUser.photoURL; const storedDisplayName = fireCMSUser.displayName; // Check what needs to be updated const needsPhotoUpdate = backendPhotoURL && backendPhotoURL !== storedPhotoURL && backendPhotoURL !== profileSyncRef.current.photoURL; const needsNameUpdate = backendDisplayName && (!storedDisplayName || storedDisplayName === "") && backendDisplayName !== profileSyncRef.current.displayName; if (needsPhotoUpdate || needsNameUpdate) { // Track what we're updating to prevent duplicate calls if (needsPhotoUpdate) profileSyncRef.current.photoURL = backendPhotoURL; if (needsNameUpdate) profileSyncRef.current.displayName = backendDisplayName; const updates: Partial = {}; if (needsPhotoUpdate) updates.photoURL = backendPhotoURL; if (needsNameUpdate) updates.displayName = backendDisplayName; console.debug("Syncing user profile from Google", { saas_uid: fireCMSUser.saas_uid, updates }); userManagement.saveUser({ ...fireCMSUser, ...updates }).then(() => { console.debug("User profile synced successfully"); }).catch((e) => { console.error("Error syncing user profile", e); }); } }, [fireCMSBackend.user?.photoURL, fireCMSBackend.user?.displayName, fireCMSUser, userManagement.loading]); let loadingOrErrorComponent; if (userManagement.loading) { loadingOrErrorComponent = ; } else if (appCheckResult.loading) { loadingOrErrorComponent = ; } // else if (appCheckResult.error) { // loadingOrErrorComponent = ; // } else if (delegatedLoginError) { console.error("Delegated login error", delegatedLoginError) loadingOrErrorComponent = checkLogin()} /> } else if (notValidUser) { console.warn("No user was found with email " + notValidUser.email); loadingOrErrorComponent = } else if (projectConfig.configError) { loadingOrErrorComponent = } else if (customizationLoading) { loadingOrErrorComponent = ; } else if (firebaseConfigLoading) { loadingOrErrorComponent = ; } else if (firebaseConfigError) { loadingOrErrorComponent = ; } else if (delegatedLoginLoading) { loadingOrErrorComponent = ; } else if (!authController.user) { loadingOrErrorComponent = ; } else if (!fireCMSUser) { loadingOrErrorComponent = ; } else if (projectConfig.blocked) { loadingOrErrorComponent = Project blocked This project has been blocked by the FireCMS team. Please contact hello@firecms.co for more information } if (loadingOrErrorComponent) { return {props.FireCMSAppBarComponent && } {loadingOrErrorComponent} ; } return ; } function NoAccessError({ authController, userManagement, projectId }: { authController: FirebaseAuthController, userManagement?: CloudUserManagement, projectId: string }) { let error = "You can request permission to the owner."; if (authController.user?.email && (userManagement?.users ?? []).map(user => user.email).includes(authController.user?.email)) { error += " The user is registered in the project, but it is out of the free plan."; } return ; } function FireCMSAppAuthenticated({ fireCMSUser, firebaseApp, projectConfig, userManagement, collectionConfigController, appConfig, authController, modeController, fireCMSBackend, FireCMSAppBarComponent, onAnalyticsEvent, basePath, baseCollectionPath, logo }: Omit & { fireCMSUser: FireCMSCloudUser; firebaseApp: FirebaseApp; projectConfig: ProjectConfig; userManagement: UserManagement; fireCMSBackend: FireCMSBackend, collectionConfigController: CollectionsConfigController; authController: FirebaseAuthController; logo?: string; }) { if (!authController.user) { throw Error("You can only use FireCMSAppAuthenticated with an authenticated user"); } const includeDataTalk = userManagement.isAdmin ?? false; const includeAdmin = userManagement.isAdmin ?? false; const adminRoutes = useMemo(() => buildAdminRoutes( includeDataTalk, includeAdmin, fireCMSBackend, projectConfig, onAnalyticsEvent), [includeDataTalk, includeAdmin, onAnalyticsEvent]); const configPermissions: CollectionEditorPermissionsBuilder = useCallback(({ user, collection }) => resolveCollectionConfigPermissions({ user: fireCMSUser, userManagement, collection }), [userManagement, fireCMSUser]); const propertyConfigsMap = useMemo(() => { const map: Record = {}; appConfig?.propertyConfigs?.forEach(field => { map[field.key] = field; }); return map; }, [appConfig?.propertyConfigs]); const exportPlugin = useExportPlugin({ exportAllowed: () => true, onAnalyticsEvent }); const importPlugin = useImportPlugin({ onAnalyticsEvent }); const historyDefaultEnabled = projectConfig.historyDefaultEnabled; const historyPlugin = useEntityHistoryPlugin({ defaultEnabled: historyDefaultEnabled, getUser: (uid) => userManagement.users.find((user) => user.uid === uid) ?? null, }); const dataEnhancementPlugin = useDataEnhancementPlugin({ host: fireCMSBackend.backendApiHost, onAnalyticsEvent }); /** * Update the browser title and icon */ useBrowserTitleAndIcon(projectConfig.projectName ?? "", projectConfig.logo ?? logo); /** * Controller for saving some user preferences locally. */ const userConfigPersistence = useBuildLocalConfigurationPersistence(); const firestoreDelegate = useFirestoreDelegate({ firebaseApp, textSearchControllerBuilder: useMemo(() => projectConfig.typesenseSearchConfig?.enabled ? (props: any) => { if (!projectConfig.typesenseSearchConfig?.region) { throw new Error("Typesense config missing region"); } const builder = buildFireCMSSearchController({ region: projectConfig.typesenseSearchConfig?.region, extensionInstanceId: projectConfig.typesenseSearchConfig?.extensionInstanceId === "firestore-typesense-search" ? "typesense-search" : (projectConfig.typesenseSearchConfig?.extensionInstanceId || "typesense-search"), collections: projectConfig.typesenseSearchConfig?.collections }); return builder(props); } : appConfig?.textSearchControllerBuilder, [projectConfig.typesenseSearchConfig, appConfig?.textSearchControllerBuilder]), firestoreIndexesBuilder: appConfig?.firestoreIndexesBuilder, localTextSearchEnabled: projectConfig.localTextSearchEnabled }); /** * Controller used for saving and fetching files in storage */ const storageSource = useFirebaseStorageSource({ firebaseApp }); const datatalkPlugin = useDataTalkPlugin({ enabled: includeDataTalk, firebaseApp: fireCMSBackend.backendFirebaseApp, userSessionsPath: `projects/${projectConfig.projectId}/users/${fireCMSBackend.user?.uid}/datatalk_sessions`, getAuthToken: fireCMSBackend.getBackendAuthToken, apiEndpoint: fireCMSBackend.backendApiHost, loadSamplePrompts: (collectionConfigController.collections ?? []).length > 0, projectId: projectConfig.projectId }); const { rootPathSuggestions } = useRootCollectionSuggestions({ projectId: projectConfig.projectId }); useEffect(() => { const status = collectionConfigController.collectionsSetup?.status; if (status === "complete" || status === "error") { navigationController?.refreshNavigation(); } }, [collectionConfigController.collectionsSetup?.status]); const saasPlugin = useSaasPlugin({ projectConfig, collectionConfigController, appConfig, fireCMSBackend, userManagement, onAnalyticsEvent, introMode: projectConfig.creationType === "new" ? "new_project" : "existing_project", historyDefaultEnabled, rootPathSuggestions }); const collectionEditorPlugin = useCollectionEditorPlugin({ collectionConfigController, configPermissions, reservedGroups: RESERVED_GROUPS, getUser: userManagement.getUser, collectionInference: buildCollectionInference(firebaseApp), getData: (path, parentPaths) => getFirestoreDataInPath(firebaseApp, path, parentPaths, 400), onAnalyticsEvent, includeIntroView: false, pathSuggestions: rootPathSuggestions?.map(s => s.path), generateCollection: buildCollectionGenerationCallback({ getAuthToken: fireCMSBackend.getBackendAuthToken, apiEndpoint: `${fireCMSBackend.backendApiHost}/collections/generate` }) }); const firebaseAdminPlugin = useFirebaseAdminPlugin({ isAdmin: userManagement.isAdmin ?? false, projectId: projectConfig.projectId, backendApiHost: fireCMSBackend.backendApiHost, getBackendAuthToken: fireCMSBackend.getBackendAuthToken, backendFirebaseApp: fireCMSBackend.backendFirebaseApp, }); const plugins: FireCMSPlugin[] = [ saasPlugin, exportPlugin, importPlugin, collectionEditorPlugin, dataEnhancementPlugin, historyPlugin, datatalkPlugin, firebaseAdminPlugin ]; const navigationController = useBuildNavigationController({ basePath, baseCollectionPath, authController, collections: projectConfig.isTrialOver ? [] : appConfig?.collections, views: projectConfig.isTrialOver ? [] : appConfig?.views, userConfigPersistence, dataSourceDelegate: firestoreDelegate, plugins }); return ( `${basePath ?? ""}/firestore?path=${encodeURIComponent(entity.path)}&doc=${encodeURIComponent(entity.id)}`} onAnalyticsEvent={onAnalyticsEvent} components={{ missingReference: MissingReferenceWidget }} > {({ context, loading }) => { let component; if (loading) { component = {FireCMSAppBarComponent && } ; } else { component = ( ); } return component; }} ); } function FireCMSScaffoldBody({ projectConfig, logo, appConfig, FireCMSAppBarComponent, adminRoutes, }: { projectConfig: ProjectConfig; logo?: string; appConfig?: FireCMSAppConfig; FireCMSAppBarComponent?: React.ComponentType>; adminRoutes: React.ReactNode; }) { const firestoreExplorerMode = useFirestoreExplorerMode(); return ( {FireCMSAppBarComponent && } {!firestoreExplorerMode && } : } > {adminRoutes} ); } function buildAdminRoutes(includeDataTalk: boolean, includeAdmin: boolean, fireCMSBackend: FireCMSBackend, projectConfig: ProjectConfig, onAnalyticsEvent?: (event: string, data?: object) => void) { const views = [ { path: "users", name: "CMS Users", group: "Admin", icon: "face", hideFromNavigation: true, view: }, { path: "roles", name: "Roles", group: "Admin", icon: "gpp_good", hideFromNavigation: true, view: }, { path: "settings", name: "Project settings", group: "Admin", icon: "settings", hideFromNavigation: true, view: } ]; if (includeAdmin) { views.push({ path: "firestore", name: "Firestore Manager", group: "Admin", icon: "manage_search", hideFromNavigation: true, view: { onAnalyticsEvent?.("fadmin:" + event, params); }} /> }); } if (includeDataTalk) { views.push({ path: "datatalk/*", name: "DataTalk", group: "Admin", icon: "settings", hideFromNavigation: true, view: { onAnalyticsEvent?.("datatalk:" + event, params); }} apiEndpoint={fireCMSBackend.backendApiHost} getAuthToken={fireCMSBackend.getBackendAuthToken} projectId={projectConfig.projectId} /> }); } return views.map(({ path, name, view }) => ) }