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,