rphlmr / supa-fly-stack

The Remix Stack for deploying to Fly with Supabase, authentication, testing, linting, formatting, etc.
MIT License
289 stars 26 forks source link

feat : add Supabase provider for Browser + RLS example #57

Closed rphlmr closed 1 year ago

rphlmr commented 1 year ago

Add Supabase Provider to use Supabase client (with RLS support) in the browser. Add a /rls route example to illustrate. Add /refresh-session route to enable session refresh (triggered by the Provider on expires). Add doc in a code comment to explain how It works. Add a migration to create a RLS table, for demo purposes. Add a type Database to have typing completion (It's only to illustrate how it works with supabase cli)

rphlmr commented 1 year ago

@barbinbrad this is for you ;)

@smartsense-as realtime is back ;)

rphlmr commented 1 year ago

EDIT

It should work now, I found a way ⚡️


🥶 Realtime is not working fine. It only works with RLS set to anon. Other than that, RLS works server-side.

With this implementation, the real-time client has an access_token = null. I'm trying to debug but I think it's not a bug.

barbinbrad commented 1 year ago

Oh wow! I'm kind of at a loss for words of gratitude without sounding cheesy. How can I help?

rphlmr commented 1 year ago

Oh wow! I'm kind of at a loss for words of gratitude without sounding cheesy. How can I help?

You are welcome ;)

To help, just try It and tell me how It feels if you can 😅

If you can't pull and try this branch, I'll try to merge asap

barbinbrad commented 1 year ago

Hm... I get the following error running npm run setup:

Invalid `prisma.note.create()` invocation in
/Users/bbarbin/Code/supa-fly-stack/app/database/seed.server.ts:53:21

  50   },
  51 });
  52 
→ 53 await prisma.note.create(
The column `user_id` does not exist in the current database.
    at RequestHandler.handleRequestError (/Users/bbarbin/Code/supa-fly-stack/node_modules/@prisma/client/runtime/index.js:34310:13)
    at RequestHandler.request (/Users/bbarbin/Code/supa-fly-stack/node_modules/@prisma/client/runtime/index.js:34293:12)

Not too familiar with prisma, but noticed that the rls_notes table had a user_id column, while the Notes table has userId column.

rphlmr commented 1 year ago

There is a trick to make It works.

First, It's always refreshed server-side. It's requireAuthSession that is responsible of refreshing accessToken

Then, we pass down this token in root.tsx (In reality It could be where you want. What matter is to do that in a "parent" route). We use this access token through a Remix hook (useMatches). Maybe I'll rewrite that to pass accessToken directly by props to SupabaseProvider (a traditional React way) , because if accessToken no more comes from root we have to change useMatchesData target 😅

Then come the fake refresh Here, we don't refresh the token. What we do is create a setInterval (useInterval hook) based on expiresIn of the token to send a post request to a Remix resource route (refresh-session). This route calls our requireAuthSession and the token is refreshed. Next, every loader is re-run (root included, this is the power of Remix), SupabaseProvider shows the change and re-create a client with the refreshed accessToken.

This is mostly to prevent users who would stay on the page without ever refreshing anything. Every time the user navigates, there is a chance that a loader/action refreshes the token (and then, SupabaseProvider handles It).

Even If you have a race condition on loader/action, you'll have the same token (Supabase option: Reuse interval of 10s, on the dashboard)

CleanShot 2022-11-22 at 17 23 21@2x
rphlmr commented 1 year ago

Hm... I get the following error running npm run setup:

Invalid `prisma.note.create()` invocation in
/Users/bbarbin/Code/supa-fly-stack/app/database/seed.server.ts:53:21

  50   },
  51 });
  52 
→ 53 await prisma.note.create(
The column `user_id` does not exist in the current database.
    at RequestHandler.handleRequestError (/Users/bbarbin/Code/supa-fly-stack/node_modules/@prisma/client/runtime/index.js:34310:13)
    at RequestHandler.request (/Users/bbarbin/Code/supa-fly-stack/node_modules/@prisma/client/runtime/index.js:34293:12)

Not too familiar with prisma, but noticed that the rls_notes table had a user_id column, while the Notes table has userId column.

Ah yes, you have to run prisma migrate deploy to apply migrations : npm run db:deploy-migration

Or just run this sql on your Supabase Dashboard SQL editor :)

barbinbrad commented 1 year ago

Even If you have a race condition on loader/action, you'll have the same token (Supabase option: Reuse interval of 10s, on the dashboard) CleanShot 2022-11-22 at 17 23 21@2x

That's perfect! Thanks for the explanation!

rphlmr commented 1 year ago

I have pushed a change

Now we pass accessToken to SupabaseProvider props. I have removed the magic thing (useMacthes)

rphlmr commented 1 year ago

Sorry for not merging this PR. I need to review It again to be sure to merge and maintain this part in time.

I have worked on a 100% supabase stack here: https://github.com/rphlmr/supa-remix-stack I'm not sure I will maintain It, but you can take some inspiration ;)

barbinbrad commented 1 year ago

Sorry for not merging this PR. I need to review It again to be sure to merge and maintain this part in time.

I have worked on a 100% supabase stack here: https://github.com/rphlmr/supa-remix-stack I'm not sure I will maintain It, but you can take some inspiration ;)

No worries at all man! This is open-source work, so again, my deepest gratitude! I've been able to use this MR in my project successfully -- although I haven't got to a need to use realtime yet. I do use useSupabase quite a bit for uploading images to storage with RLS and it works perfectly.

I think this client-side approach is much better than the send bytes through remix server:


const ProfilePhotoForm = ({ user }: ProfilePhotoFormProps) => {
  const { supabase } = useSupabase();
  const fileInputRef = useRef<HTMLInputElement>(null);
  const notification = useNotification();
  const submit = useSubmit();

  const uploadImage = async (e: ChangeEvent<HTMLInputElement>) => {
    if (e.target.files && supabase) {
      const avatarFile = e.target.files[0];
      const fileExtension = avatarFile.name.substring(
        avatarFile.name.lastIndexOf(".") + 1
      );

      const imageUpload = await supabase.storage
        .from("avatars")
        .upload(`${user.id}.${fileExtension}`, avatarFile, {
          cacheControl: `${12 * 60 * 60}`,
          upsert: true,
        });

      if (imageUpload.error) {
        notification.copyableError(imageUpload.error, "Failed to upload image");
      }

      if (imageUpload.data?.path) {
        submitAvatarUrl(imageUpload.data.path);
      }
    }
  };

  const deleteImage = async () => {
    if (supabase) {
      const imageDelete = await supabase.storage
        .from("avatars")
        .remove([`${user.id}.png`]);

      if (imageDelete.error) {
        notification.copyableError(imageDelete.error, "Failed to remove image");
      }

      submitAvatarUrl(null);
    }
  };

  const submitAvatarUrl = (path: string | null) => {
    const formData = new FormData();
    formData.append("intent", "photo");
    if (path) formData.append("path", path);
    submit(formData, {
      method: "post",
      action: "/app/account/profile",
    });
  };

  return (
    <VStack w="full" spacing={2} px={8}>
      <Avatar size="2xl" path={user?.avatarUrl} title={user?.fullName ?? ""} />
      <InputGroup w="auto">
        <Input
          ref={fileInputRef}
          id="avatar-upload"
          type="file"
          hidden
          accept="image/*"
          onChange={uploadImage}
        />
        <Button
          variant="solid"
          colorScheme="brand"
          onClick={() => {
            if (fileInputRef.current) fileInputRef.current.click();
          }}
        >
          {user.avatarUrl ? "Change" : "Upload"}
        </Button>
      </InputGroup>
      {user.avatarUrl && (
        <Button variant="outline" onClick={deleteImage}>
          Remove
        </Button>
      )}
    </VStack>
  );
};

export default ProfilePhotoForm;
barbinbrad commented 1 year ago

I have worked on a 100% supabase stack here: https://github.com/rphlmr/supa-remix-stack I'm not sure I will maintain It, but you can take some inspiration ;)

What would you say are the major benefits/differences with the 100% supabase stack?

rphlmr commented 1 year ago

Oh yes, this is a good approach!

It's a matter of choice to choose the front or backend way to upload images.

What would you say are the major benefits/differences with the 100% supabase stack?

The major benefit is that it's officially maintained by supabase (thru their auth package). The drawback is that using supabase SDK to talk to your database adds an extra layer between your backend and your DB. The SDK points to a gotrue API and (on my projects) it adds a 200ms delay per request 😬.

With SDK : front > back > gotrue > db > gotrue > back > front Without : front > back > db > back > front

Depending on everyone's needs it can be fine. My client wants the more speed he can have, so, this is why I still use Prisma and this stack.

barbinbrad commented 1 year ago

Depending on everyone's needs it can be fine. My client wants the more speed he can have, so, this is why I still use Prisma and this stack.

Thanks for the heads up. I think I'm going to eliminate prisma from my project too. Currently, I use the supabase server-side for authz, and, I get Lighthouse performance scores of 100 on just about every page. But the caveat is that I'm running supabase locally from the CLI. I think that this kind of speed could be replicated by having the supabase containers and the remix app in the same cluster, but that'd mean self-hosting supabase (I think), which isn't something I'd love to do.

My ERP project is getting pretty large and stable (much thanks to you), I'm going to open-source it when I get to an MVP type status, but if you ever want see any of the code, I'd be happy to give you access.

rphlmr commented 1 year ago

I'm glad It helps you 🤩

I'm working on a new stack mixing Remix, Supabase (with Prisma), and Stripe. Using Stripe as a CMS for subscription tiers and handling all of this with a Remix/Supabase project.

rphlmr commented 1 year ago

I'm closing this pull request in favor of https://github.com/rphlmr/supa-remix-stack. I keep the branch in case of ;)