diff --git a/examples/example_pro/src/SupabaseApp/SupabaseApp.tsx b/examples/example_pro/src/SupabaseApp/SupabaseApp.tsx index f6386111c..b7227d94c 100644 --- a/examples/example_pro/src/SupabaseApp/SupabaseApp.tsx +++ b/examples/example_pro/src/SupabaseApp/SupabaseApp.tsx @@ -6,7 +6,6 @@ import "@fontsource/jetbrains-mono"; import { AppBar, CircularProgressCenter, - DataSourceDelegate, Drawer, FireCMS, ModeControllerProvider, @@ -16,72 +15,39 @@ import { SnackbarProvider, useBuildLocalConfigurationPersistence, useBuildModeController, - useBuildNavigationController, - useValidateAuthenticator + useBuildNavigationController } from "@firecms/core"; - -import { useFirebaseStorageSource, useInitialiseFirebase, } from "@firecms/firebase"; - -import { productsCollection } from "./collections/products_collection"; -import { CenteredView } from "@firecms/ui"; import { createClient } from "@supabase/supabase-js"; +import { useSupabaseAuthController } from "./useSupabaseAuthController"; +import { useSupabaseDelegate } from "./useSupabaseDataSourceDelegate"; +import { productsCollection } from "./collections/products_collection"; -const NEXT_PUBLIC_SUPABASE_URL="https://aqgwxulqziwzfzxkbhau.supabase.co" -const NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFxZ3d4dWxxeml3emZ6eGtiaGF1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MjUwNjU2NTcsImV4cCI6MjA0MDY0MTY1N30.NwtGlIkzoGGOJprGIfCQ-Ps_ZS5tevB2OFDtBlgrgBE" +const NEXT_PUBLIC_SUPABASE_URL = "https://aqgwxulqziwzfzxkbhau.supabase.co" +const NEXT_PUBLIC_SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFxZ3d4dWxxeml3emZ6eGtiaGF1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MjUwNjU2NTcsImV4cCI6MjA0MDY0MTY1N30.NwtGlIkzoGGOJprGIfCQ-Ps_ZS5tevB2OFDtBlgrgBE" const supabase = createClient(NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY) -async function getProducts() { - const { data } = await supabase.from("Products").select(); - console.log(data); -} -getProducts(); function SupabaseApp() { - return "Yo"; - // const name = "My FireCMS App"; - // - // const { - // firebaseApp, - // firebaseConfigLoading, - // configError - // } = useInitialiseFirebase({ - // firebaseConfig - // }); - // - // const { app } = useInitRealmMongodb(atlasConfig); - // - // /** - // * Controller used to manage the dark or light color mode - // */ - // const modeController = useBuildModeController(); - // - // /** - // * Controller for saving some user preferences locally. - // */ - // const userConfigPersistence = useBuildLocalConfigurationPersistence(); - // - // const authController: MongoAuthController = useMongoDBAuthController({ - // app - // }); - // - // const cluster = "mongodb-atlas" - // const database = "todo" - // - // const mongoDataSourceDelegate = useMongoDBDelegate({ - // app, - // cluster, - // database - // }); - // - // /** - // * Controller used for saving and fetching files in storage - // */ - // const storageSource = useFirebaseStorageSource({ - // firebaseApp - // }); - // + const name = "My FireCMS App"; + + /** + * Controller used to manage the dark or light color mode + */ + const modeController = useBuildModeController(); + + /** + * Controller for saving some user preferences locally. + */ + const userConfigPersistence = useBuildLocalConfigurationPersistence(); + + const authController = useSupabaseAuthController({ + supabase + }); + + const supabaseDataSourceDelegate = useSupabaseDelegate({ supabase }); + // /** // * Validate authenticator // */ @@ -92,75 +58,75 @@ function SupabaseApp() { // } = useValidateAuthenticator({ // authController, // authenticator: () => true, - // dataSourceDelegate: mongoDataSourceDelegate, + // dataSourceDelegate: supbaseDataSourceDelegate, // storageSource // }); - // - // const navigationController = useBuildNavigationController({ - // collections: [productsCollection], - // authController, - // dataSourceDelegate: mongoDataSourceDelegate - // }); - // + + const navigationController = useBuildNavigationController({ + collections: [productsCollection], + authController, + dataSourceDelegate: supabaseDataSourceDelegate + }); + // if (firebaseConfigLoading || !firebaseApp) { // return <> // // ; // } - // + // if (configError) { // return {configError}; // } - // - // return ( - // - // - // - // - // {({ - // context, - // loading - // }) => { - // - // let component; - // if (loading || authLoading) { - // component = ; - // } else { - // if (!canAccessMainView) { - // component = ( - // - // ); - // } else { - // component = ( - // - // - // - // - // - // - // ); - // } - // } - // - // return component; - // }} - // - // - // - // ); + + return ( + + + + + {({ + context, + loading + }) => { + + let component; + if (loading) { + component = ; + } else { + // if (!canAccessMainView) { + // component = ( + // + // ); + // } else { + component = ( + + + + + + + ); + // } + } + + return component; + }} + + + + ); } export default SupabaseApp; diff --git a/examples/example_pro/src/SupabaseApp/collections/products_collection.tsx b/examples/example_pro/src/SupabaseApp/collections/products_collection.tsx index 1b87548a0..ab166cd33 100644 --- a/examples/example_pro/src/SupabaseApp/collections/products_collection.tsx +++ b/examples/example_pro/src/SupabaseApp/collections/products_collection.tsx @@ -2,7 +2,7 @@ import { buildCollection } from "@firecms/core"; export const productsCollection = buildCollection({ id: "products", - path: "products", + path: "Products", name: "Products", singularName: "Product", group: "E-commerce", diff --git a/examples/example_pro/src/SupabaseApp/useSupabaseAuthController.tsx b/examples/example_pro/src/SupabaseApp/useSupabaseAuthController.tsx new file mode 100644 index 000000000..a6e0d6264 --- /dev/null +++ b/examples/example_pro/src/SupabaseApp/useSupabaseAuthController.tsx @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useState } from "react"; +import { SupabaseClient, User as SupabaseUser } from "@supabase/supabase-js"; +import { AuthController, Role, User } from "@firecms/core"; + +export type SupabaseSignInProvider = "google" | "facebook" | "github" | "gitlab" | "bitbucket"; + +export type SupabaseAuthController = AuthController & { + setUser: (user: any | null) => Promise; + setRoles: (roles: Role[] | undefined) => void; + authProviderError: any; + googleLogin: () => Promise; + skipLogin: () => void; + loginSkipped: boolean; + emailPasswordLogin: (email: string, password: string) => Promise; + createUserWithEmailAndPassword: (email: string, password: string) => Promise; + sendPasswordResetEmail: (email: string) => Promise; + extra?: ExtraData; + setExtra: (extra: ExtraData) => void; +} + +export interface SupabaseAuthControllerProps { + loading?: boolean; + onSignOut?: () => void; + defineRolesFor?: (user: User) => Promise | Role[] | undefined; + supabase: SupabaseClient +} + +/** + * Use this hook to build an {@link AuthController} based on Supabase Auth + * @group Supabase + */ +export const useSupabaseAuthController = ({ + loading, + onSignOut: onSignOutProp, + defineRolesFor, + supabase + }: SupabaseAuthControllerProps): SupabaseAuthController => { + + const [loggedUser, setLoggedUser] = useState(undefined); + const [authError, setAuthError] = useState(); + const [initialLoading, setInitialLoading] = useState(true); + const [authLoading, setAuthLoading] = useState(true); + const [loginSkipped, setLoginSkipped] = useState(false); + const [roles, setRoles] = useState(); + const [extra, setExtra] = useState(); + + const updateUser = useCallback(async (supabaseUser: SupabaseUser | null, initialize?: boolean) => { + if (loading) return; + const user = convertSupabaseUserToUser(supabaseUser); + if (defineRolesFor && user) { + const userRoles = await defineRolesFor(user); + setRoles(userRoles); + user.roles = userRoles; + } + setLoggedUser(user); + setAuthLoading(false); + if (initialize) { + setInitialLoading(false); + } + }, [loading, defineRolesFor]); + + // Listen for authentication state changes. + useEffect(() => { + const { data: { subscription } } = supabase.auth.onAuthStateChange( + async (event, session) => { + console.log("onAuthStateChange", event, session); + if (session?.user) { + await updateUser(session.user, true); + } else { + setLoggedUser(null); + setRoles(undefined); + } + } + ); + // Cleanup subscription on unmount + return () => subscription?.unsubscribe(); + }, [supabase, updateUser]); + + useEffect(() => { + (async () => { + const { + data: { user }, + error + } = await supabase.auth.getUser(); + if (error) { + setAuthError(error); + setInitialLoading(false); + } else { + await updateUser(user, true); + } + })(); + }, [supabase, updateUser]); + + const emailPasswordLogin = useCallback(async (email: string, password: string) => { + setAuthLoading(true); + const { + error, + data: { user } + } = await supabase.auth.signInWithPassword({ + email, + password + }); + if (error) { + setAuthError(error); + } else { + await updateUser(user, true); + } + setAuthLoading(false); + }, [supabase, updateUser]); + + const createUserWithEmailAndPassword = useCallback(async (email: string, password: string) => { + setAuthLoading(true); + const { + data, + error + } = await supabase.auth.signUp({ + email, + password + }); + if (error) { + setAuthError(error); + } else { + await updateUser(data.user, true); // Adjusted to use data.user + } + setAuthLoading(false); + }, [supabase, updateUser]); + + const sendPasswordResetEmail = useCallback(async (email: string) => { + const { error } = await supabase.auth.resetPasswordForEmail(email); + if (error) { + setAuthError(error); + } + }, [supabase]); + + const onSignOut = useCallback(async () => { + const { error } = await supabase.auth.signOut(); + if (error) { + setAuthError(error); + } else { + setLoggedUser(null); + setRoles(undefined); + onSignOutProp && onSignOutProp(); + } + setLoginSkipped(false); + }, [supabase, onSignOutProp]); + + const googleLogin = useCallback(async () => { + const { error } = await supabase.auth.signInWithOAuth({ + provider: "google", + options: { + redirectTo: window.location.origin + } + }); + if (error) { + setAuthError(error); + } + }, [supabase]); + + const skipLogin = useCallback(() => { + setLoginSkipped(true); + setLoggedUser(null); + setRoles(undefined); + }, []); + + const supabaseUserWrapper = loggedUser ? { + ...loggedUser, + supabaseUser: loggedUser + } : null; + + const getAuthToken = useCallback(async (): Promise => { + const session = await supabase.auth.getSession(); + if (!session.data.session) { + throw new Error("User is not logged in"); + } + return session.data.session.access_token + }, [supabase]); + + return { + user: supabaseUserWrapper, + roles, + setUser: updateUser, + setRoles, + getAuthToken, + authLoading, + initialLoading: loading || initialLoading, + authProviderError: authError, + signOut: onSignOut, + googleLogin, + skipLogin, + loginSkipped, + emailPasswordLogin, + createUserWithEmailAndPassword, + sendPasswordResetEmail, + extra, + setExtra + }; +}; + +const convertSupabaseUserToUser = (supabaseUser: SupabaseUser | null): User | null => { + if (!supabaseUser) return null; + + return { + uid: supabaseUser.id, + displayName: supabaseUser.user_metadata?.full_name || null, + email: supabaseUser.email ?? null, + photoURL: supabaseUser.user_metadata?.avatar_url || null, + providerId: "unknown", // Supabase does not expose provider info directly + isAnonymous: !supabaseUser.email, + roles: [] + }; +}; diff --git a/examples/example_pro/src/SupabaseApp/useSupabaseDataSourceDelegate.tsx b/examples/example_pro/src/SupabaseApp/useSupabaseDataSourceDelegate.tsx new file mode 100644 index 000000000..2cdeb6f31 --- /dev/null +++ b/examples/example_pro/src/SupabaseApp/useSupabaseDataSourceDelegate.tsx @@ -0,0 +1,340 @@ +import { + DataSourceDelegate, + DeleteEntityProps, + Entity, + EntityCollection, + FetchCollectionDelegateProps, + FetchEntityProps, + FilterValues, + ResolvedEntityCollection, + SaveEntityDelegateProps, + WhereFilterOp +} from "@firecms/core"; + +import { SupabaseClient } from "@supabase/supabase-js"; +import { useCallback } from "react"; + +/** + * @group Supabase + */ +export interface SupabaseDataSourceProps { + supabase: SupabaseClient; +} + +export type SupabaseDelegate = DataSourceDelegate & { + initTextSearch: (props: { + path: string, + databaseId?: string, + collection?: EntityCollection | ResolvedEntityCollection + }) => Promise, +} + +/** + * Use this hook to build a {@link DataSource} based on Supabase + * @param supabaseClient + * @group Supabase + */ +export function useSupabaseDelegate({ supabase }: SupabaseDataSourceProps): SupabaseDelegate { + const buildQuery = useCallback(async ( + path: string, + filter: FilterValues | undefined, + orderBy: string | undefined, + order: "desc" | "asc" | undefined, + startAfter: any[] | undefined, + limit: number | undefined, + count: boolean + ) => { + let query = supabase.from(path).select("*", { count: count ? "exact" : undefined }); + + if (filter) { + Object.entries(filter).forEach(([key, value]) => { + const [op, val] = value as [WhereFilterOp, any]; + // @ts-ignore + query = query[op](key, val); + }); + } + + if (orderBy && order) { + query = query.order(orderBy, { ascending: order === "asc" }); + } + + if (startAfter) { + query = query.gt("id", startAfter[startAfter.length - 1]); + } + + if (limit) { + query = query.limit(limit); + } + + return query; + }, [supabase]); + + const getAndBuildEntity = useCallback(async >( + path: string, + entityId: string, + databaseId?: string + ): Promise | undefined> => { + const { + data, + error + } = await supabase + .from(path) + .select("*") + .eq("id", entityId) + .single(); + + if (error) { + throw error; + } + return createEntityFromDocument(data, databaseId); + }, [supabase]); + + // const listenEntity = useCallback(( + // { + // path, + // entityId, + // collection, + // onUpdate, + // onError + // }: ListenEntityProps + // ): () => void => { + // const subscription = supabaseClient + // .from(`${path}:id=eq.${entityId}`) + // .on("*", payload => onUpdate(createEntityFromDocument(payload.new, collection?.databaseId))) + // .subscribe(); + // + // return () => { + // subscription.unsubscribe(); + // }; + // }, [supabaseClient]); + + const fetchCollection = useCallback(async >( + props: FetchCollectionDelegateProps + ): Promise[]> => { + const { + path, + filter, + limit, + orderBy, + order, + startAfter + } = props; + const query = await buildQuery(path, filter, orderBy, order, startAfter, limit, false); + const { + data, + error + } = await query; + console.log("new data", { props }, data); + + if (error) { + throw error; + } + return data.map((doc: any) => createEntityFromDocument(doc)); + }, [buildQuery]); + + // const listenCollection = useCallback(>( + // { path, onUpdate, onError }: ListenEntityProps + // ): () => void => { + // const subscription = supabaseClient + // .from(path) + // .on('INSERT', (payload) => { + // // For inserted entities + // const newEntity = { + // id: payload.new.id, + // path, + // values: payload.new + // } as Entity; + // onUpdate(prevEntities => [...prevEntities, newEntity]); + // }) + // .on('UPDATE', (payload) => { + // // For updated entities, replace the old one in the list + // const updatedEntity = { + // id: payload.new.id, + // path, + // values: payload.new + // } as Entity; + // onUpdate(prevEntities => { + // return prevEntities.map(entity => + // entity.id === updatedEntity.id ? updatedEntity : entity + // ); + // }); + // }) + // .on('DELETE', (payload) => { + // // For deleted entities + // onUpdate(prevEntities => + // prevEntities.filter(entity => entity.id !== payload.old.id) + // ); + // }) + // .subscribe(); + // + // return () => { + // subscription.unsubscribe(); // Unsubscribes from real-time updates + // }; + // }, [supabaseClient]); + + const fetchEntity = useCallback(>( + props: FetchEntityProps + ): Promise | undefined> => { + const { + path, + entityId, + collection + } = props; + return getAndBuildEntity(path, entityId, collection?.databaseId); + }, [getAndBuildEntity]); + + const saveEntity = useCallback(>( + props: SaveEntityDelegateProps + ): Promise> => { + const { + path, + entityId, + values, + collection, + status + } = props; + if (entityId) { + return Promise.resolve(supabase + .from(path) + .update(values) + .eq("id", entityId) + .then(() => { + return { + id: entityId, + path, + values: values as M + } as Entity + })); + } else { + const newId = crypto.randomUUID(); + return Promise.resolve(supabase + .from(path) + .insert({ + id: newId, + ...values + }) + .single() + .then(({ data }) => { + if (!data) { + throw new Error("No data returned"); + } + return { + // gen new uuid + id: newId, + path, + values: values as M + } + })); + } + }, [supabase]); + + const deleteEntity = useCallback(>( + props: DeleteEntityProps + ): Promise => { + const { entity } = props; + return Promise.resolve(supabase + .from(entity.path) + .delete() + .eq("id", entity.id) + .then(() => { + })); + }, [supabase]); + + const checkUniqueField = useCallback(async ( + path: string, + name: string, + value: any, + entityId?: string, + collection?: EntityCollection + ): Promise => { + const { + data, + error + } = await supabase + .from(path) + .select("*") + .eq(name, value) + .neq("id", entityId) + .single(); + + if (error) { + throw error; + } + return !data; + }, [supabase]); + + const generateEntityId = useCallback((path: string): string => { + return crypto.randomUUID(); + }, []); + + const countEntities = useCallback(async ( + props: FetchCollectionDelegateProps + ): Promise => { + const { + path, + filter + } = props; + const { + count, + error + } = await buildQuery(path, filter, undefined, undefined, undefined, undefined, true); + + if (error) { + throw error; + } + if (!count) { + return 0; + } + return count; + }, [buildQuery]); + + const isFilterCombinationValid = useCallback((props: { + path: string, + collection: EntityCollection, + filterValues: FilterValues, + sortBy?: [string, "asc" | "desc"] + }): boolean => { + return true; + }, []); + + return { + key: "supabase", + setDateToMidnight: (date: Date) => { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + }, + delegateToCMSModel: (data) => data, + cmsToDelegateModel: (data) => data, + currentTime: () => new Date(), + initialised: true, + authenticated: true, + fetchCollection, + // listenCollection, + fetchEntity, + // listenEntity, + saveEntity, + deleteEntity, + checkUniqueField, + generateEntityId, + countEntities, + isFilterCombinationValid, + initTextSearch: async ({ + path, + databaseId, + collection + }): Promise => { + return true; + } + }; +} + +const createEntityFromDocument = >( + data: any, + databaseId?: string +): Entity => { + return { + id: data.id, + path: data.path, + values: data, + databaseId + }; +}; diff --git a/packages/firebase_firecms/src/hooks/useFirestoreDelegate.ts b/packages/firebase_firecms/src/hooks/useFirestoreDelegate.ts index 510b1bad7..cc18f339b 100644 --- a/packages/firebase_firecms/src/hooks/useFirestoreDelegate.ts +++ b/packages/firebase_firecms/src/hooks/useFirestoreDelegate.ts @@ -1,5 +1,4 @@ import { - DataSource, DataSourceDelegate, DeleteEntityProps, Entity,