diff --git a/packages/demo/public/auth-callback.html b/packages/demo/public/auth-callback.html new file mode 100644 index 0000000..ab2d5f0 --- /dev/null +++ b/packages/demo/public/auth-callback.html @@ -0,0 +1,140 @@ + + + + + + + + + Atomic CRM + + + + + +
+
+
Loading...
+
+
+ + + diff --git a/packages/ra-supabase-core/src/authProvider.ts b/packages/ra-supabase-core/src/authProvider.ts index f73c5ff..bcaef4f 100644 --- a/packages/ra-supabase-core/src/authProvider.ts +++ b/packages/ra-supabase-core/src/authProvider.ts @@ -1,5 +1,6 @@ import { Provider, SupabaseClient, User } from '@supabase/supabase-js'; import { AuthProvider, UserIdentity } from 'ra-core'; +import { getSearchString } from './getSearchString'; export const supabaseAuthProvider = ( client: SupabaseClient, @@ -93,9 +94,12 @@ export const supabaseAuthProvider = ( if (type === 'recovery' || type === 'invite') { if (access_token && refresh_token) { return { - redirectTo: `${ - redirectTo ? `${redirectTo}/` : '/' - }set-password?access_token=${access_token}&refresh_token=${refresh_token}&type=${type}`, + redirectTo: () => ({ + pathname: redirectTo + ? `${redirectTo}/set-password` + : '/set-password', + search: `access_token=${access_token}&refresh_token=${refresh_token}&type=${type}`, + }), }; } @@ -108,24 +112,32 @@ export const supabaseAuthProvider = ( }, async checkAuth() { // Users are on the set-password page, nothing to do - if (window.location.pathname === '/set-password') { + if ( + window.location.pathname === '/set-password' || + window.location.hash.includes('#/set-password') + ) { return; } // Users are on the forgot-password page, nothing to do - if (window.location.pathname === '/forgot-password') { + if ( + window.location.pathname === '/forgot-password' || + window.location.hash.includes('#/forgot-password') + ) { return; } const { access_token, refresh_token, type } = getUrlParams(); - // Users have reset their password or have just been invited and must set a new password if (type === 'recovery' || type === 'invite') { if (access_token && refresh_token) { // eslint-disable-next-line no-throw-literal throw { - redirectTo: `${ - redirectTo ? `${redirectTo}/` : '/' - }set-password?access_token=${access_token}&refresh_token=${refresh_token}&type=${type}`, + redirectTo: () => ({ + pathname: redirectTo + ? `${redirectTo}/set-password` + : '/set-password', + search: `access_token=${access_token}&refresh_token=${refresh_token}&type=${type}`, + }), message: false, }; } @@ -145,6 +157,20 @@ export const supabaseAuthProvider = ( return Promise.resolve(); }, async getPermissions() { + if ( + window.location.pathname === '/set-password' || + window.location.hash.includes('#/set-password') + ) { + return; + } + // Users are on the forgot-password page, nothing to do + if ( + window.location.pathname === '/forgot-password' || + window.location.hash.includes('#/forgot-password') + ) { + return; + } + const { data, error } = await client.auth.getUser(); if (error) { throw error; @@ -164,7 +190,6 @@ export const supabaseAuthProvider = ( if (typeof getIdentity === 'function') { authProvider.getIdentity = async () => { const { data } = await client.auth.getUser(); - if (data.user == null) { throw new Error(); } @@ -222,10 +247,8 @@ export type ResetPasswordParams = { }; const getUrlParams = () => { - const urlSearchParams = new URLSearchParams( - window.location.hash.substring(1) - ); - + const searchStr = getSearchString(); + const urlSearchParams = new URLSearchParams(searchStr); const access_token = urlSearchParams.get('access_token'); const refresh_token = urlSearchParams.get('refresh_token'); const type = urlSearchParams.get('type'); diff --git a/packages/ra-supabase-core/src/getSearchString.ts b/packages/ra-supabase-core/src/getSearchString.ts new file mode 100644 index 0000000..f6373db --- /dev/null +++ b/packages/ra-supabase-core/src/getSearchString.ts @@ -0,0 +1,10 @@ +export function getSearchString() { + const search = window.location.search; + const hash = window.location.hash.substring(1); + + return search && search !== '' + ? search + : hash.includes('?') + ? hash.split('?')[1] + : hash; +} diff --git a/packages/ra-supabase-core/src/useSupabaseAccessToken.test.tsx b/packages/ra-supabase-core/src/useSupabaseAccessToken.test.tsx index 5fb814f..8bcef82 100644 --- a/packages/ra-supabase-core/src/useSupabaseAccessToken.test.tsx +++ b/packages/ra-supabase-core/src/useSupabaseAccessToken.test.tsx @@ -7,7 +7,7 @@ import { } from './useSupabaseAccessToken'; // TODO: fix those tests -describe.skip('useSupabaseAccessToken', () => { +describe('useSupabaseAccessToken', () => { const UseSupabaseAccessToken = (props?: UseSupabaseAccessTokenOptions) => { const token = useSupabaseAccessToken(props); @@ -34,6 +34,26 @@ describe.skip('useSupabaseAccessToken', () => { }); }); + test('should return the access token if present in the hash route', async () => { + window.history.pushState( + {}, + 'React Admin', + '/set-password#access_token=bazinga' + ); + + const { queryByText } = render( + + + + + + ); + + await waitFor(() => { + expect(queryByText('bazinga')).not.toBeNull(); + }); + }); + test('should return the access token from the provided key if present in the URL', async () => { window.history.pushState( {}, @@ -54,7 +74,7 @@ describe.skip('useSupabaseAccessToken', () => { }); }); - test('should redirect users if the access token is not present in the URL', async () => { + test.skip('should redirect users if the access token is not present in the URL', async () => { window.history.pushState({}, 'React Admin', '/set-password'); render( @@ -70,7 +90,7 @@ describe.skip('useSupabaseAccessToken', () => { }); }); - test('should redirect users to the provided path if the access token is not present in the URL', async () => { + test.skip('should redirect users to the provided path if the access token is not present in the URL', async () => { window.history.pushState({}, 'React Admin', '/set-password'); render( @@ -86,7 +106,7 @@ describe.skip('useSupabaseAccessToken', () => { }); }); - test('should not redirect users if the access token is not present in the URL and redirectTo is false', async () => { + test.skip('should not redirect users if the access token is not present in the URL and redirectTo is false', async () => { window.history.pushState({}, 'React Admin', '/set-password'); render( diff --git a/packages/ra-supabase-core/src/useSupabaseAccessToken.ts b/packages/ra-supabase-core/src/useSupabaseAccessToken.ts index 553c144..2dcf028 100644 --- a/packages/ra-supabase-core/src/useSupabaseAccessToken.ts +++ b/packages/ra-supabase-core/src/useSupabaseAccessToken.ts @@ -1,5 +1,6 @@ import { useRedirect } from 'ra-core'; import { useEffect } from 'react'; +import { getSearchString } from './getSearchString'; /** * This hook gets the access_token from supabase in the current browser URL and redirects to the specified page (/ by default) if there is none. @@ -34,10 +35,8 @@ export const useSupabaseAccessToken = ({ }: UseSupabaseAccessTokenOptions = {}) => { const redirect = useRedirect(); - const urlSearchParams = new URLSearchParams( - window.location.search.substr(1) - ); - + const searchStr = getSearchString(); + const urlSearchParams = new URLSearchParams(searchStr); const access_token = urlSearchParams.get(parameterName); useEffect(() => { if (access_token == null) { diff --git a/packages/ra-supabase/README.md b/packages/ra-supabase/README.md index 10a9aff..36e9c2a 100644 --- a/packages/ra-supabase/README.md +++ b/packages/ra-supabase/README.md @@ -18,7 +18,10 @@ npm install ra-supabase // in supabase.js import { createClient } from '@supabase/supabase-js'; -export const supabaseClient = createClient('YOUR_SUPABASE_URL', 'YOUR_SUPABASE_ANON_KEY'); +export const supabaseClient = createClient( + 'YOUR_SUPABASE_URL', + 'YOUR_SUPABASE_ANON_KEY' +); // in dataProvider.js import { supabaseDataProvider } from 'ra-supabase'; @@ -27,7 +30,7 @@ import { supabaseClient } from './supabase'; export const dataProvider = supabaseDataProvider({ instanceUrl: 'YOUR_SUPABASE_URL', apiKey: 'YOUR_SUPABASE_ANON_KEY', - supabaseClient + supabaseClient, }); // in authProvider.js @@ -86,6 +89,45 @@ export const MyAdmin = () => ( You must wrap your `` inside a `` as supabase use hash parameters for passing authentication tokens. +### Using Hash Router + +Supabase uses URL hash links for its redirections. This can cause conflicts if you use a HashRouter. For this reason, we recommend using the BrowserRouter. + +If you want to use the HashRouter, you'll need to modify the code. + +1. Create a custom `auth-callback.html` file inside your public folder. This file will intercept the supabase redirect and rewrite the URL to prevent conflicts with the HashRouter. For example, see `packages/demo/public/auth-callback.html`. +2. Remove `BrowserRouter` from your `App.ts` + +#### Configuring an hosted Supabase instance + +3. Go to your Supabase dashboard **Authentication** section +4. In **URL Configuration**, add the following URL in the **Redirect URLs** section: `YOUR_APPLICATION_URL/auth-callback.html` +5. In **Email Templates**, change the `"{{ .ConfirmationURL }}"` to `"{{ .ConfirmationURL }}/auth-callback.html"` + +##### Configuring a local Supabase instance + +3. Go to your `config.toml` file +4. In `[auth]` section set `site_url` to your application URL +5. In `[auth]`, add the following URL in the `additional_redirect_urls = [{APPLICATION_URL}}/auth-callback.html"]` +6. Add an `[auth.email.template.{TYPE}]` section with the following option : + +``` +[auth.email.template.TYPE] +subject = {TYPE_MESSAGE} +content_path = "./supabase/templates/{TYPE}.html" +``` + +In `{TYPE}.html` set the `auth-callback` redirection + +```HTML + + +

{TYPE_MESSAGE}

+

{TYPE_CTA}

+ + +``` + ## Features ### DataProvider @@ -102,18 +144,14 @@ const postFilters = [ , ]; -export const PostList = () => ( - - ... - -); +export const PostList = () => ...; ``` See the [PostgREST documentation](https://postgrest.org/en/stable/api.html#operators) for a list of supported operators. #### RLS -As users authenticate through supabase, you can leverage [Row Level Security](https://supabase.com/docs/guides/auth/row-level-security). Users identity will be propagated through the dataProvider if you provided the public API (anon) key. Keep in mind that passing the `service_role` key will bypass Row Level Security. This is not recommended. +As users authenticate through supabase, you can leverage [Row Level Security](https://supabase.com/docs/guides/auth/row-level-security). Users identity will be propagated through the dataProvider if you provided the public API (anon) key. Keep in mind that passing the `service_role` key will bypass Row Level Security. This is not recommended. #### Customizing the dataProvider @@ -132,7 +170,7 @@ export const dataProvider = supabaseDataProvider({ ['some_table', ['custom_id']], ['another_table', ['first_column', 'second_column']], ]), - schema: () => localStorage.getItem("schema") || "api", + schema: () => localStorage.getItem('schema') || 'api', }); ``` @@ -172,10 +210,37 @@ export const MyAdmin = () => ( This requires you to configure your supabase instance: +##### Configuring a local Supabase instance + +1. Go to your `config.toml` file +2. In `[auth]` section set `site_url` to your application URL +3. In `[auth]`, add the following URL in the `additional_redirect_urls = [{APPLICATION_URL}}/auth-callback"]` +4. Add an `[auth.email.template.invite]` section with the following option + +``` +[auth.email.template.invite] +subject = "You have been invited" +content_path = "./supabase/templates/invite.html" +``` + +In `invite.html` set the `auth-callback` redirection + +```HTML + + +

You have been invited

+

You have been invited to create a user on {{ .SiteURL }}. Follow this link to accept the invite:

+

Accept the invite

+ + +``` + +#### Configuring an hosted Supabase instance + 1. Go to your dashboard **Authentication** section 1. In **URL Configuration**, set **Site URL** to your application URL 1. In **URL Configuration**, add the following URL in the **Redirect URLs** section: `YOUR_APPLICATION_URL/auth-callback` -1. In **Email Templates**, change the `"{{ .ConfirmationURL }}"` to `"{{ .ConfirmationURL }}/auth-callback"` +1. In **Email Templates**, change the `"{{ .ConfirmationURL }}"` to `"{{ .ConfirmationURL }}/auth-callback"` You can now add the `/set-password` custom route: @@ -207,16 +272,44 @@ export const MyAdmin = () => ( ); ``` +For HashRouter see [Using Hash Router](#using-hash-router). + ### Password Reset When Forgotten If users forgot their password, they can request for a reset if you add the `/forgot-password` custom route. You should also set up the [`/set-password` custom route](#invitation-handling) to allow them to choose their new password. This requires you to configure your supabase instance: +##### Configuring a local Supabase instance + +1. Go to your `config.toml` file +2. In `[auth]` section set `site_url` to your application URL +3. In `[auth]`, add the following URL in the `additional_redirect_urls = [{APPLICATION_URL}}/auth-callback"]` +4. Add an `[auth.email.template.recovery]` section with the following option + +``` +[auth.email.template.recovery] +subject = "Reset Password" +content_path = "./supabase/templates/recovery.html" +``` + +In `recovery.html` set the `auth-callback` redirection + +```HTML + + +

Reset Password

+

Reset your password

+ + +``` + +#### Configuring an hosted Supabase instance + 1. Go to your dashboard **Authentication** section 1. In **URL Configuration**, set **Site URL** to your application URL 1. In **URL Configuration**, add the following URL in the **Redirect URLs** section: `YOUR_APPLICATION_URL/auth-callback` -1. In **Email Templates**, change the `"{{ .ConfirmationURL }}"` to `"{{ .ConfirmationURL }}/auth-callback"` +1. In **Email Templates**, change the `"{{ .ConfirmationURL }}"` to `"{{ .ConfirmationURL }}/auth-callback"` You can now add the `/forgot-password` and `/set-password` custom routes: @@ -252,6 +345,8 @@ export const MyAdmin = () => ( ); ``` +For HashRouter see [Using Hash Router](#using-hash-router). + #### OAuth Authentication To setup OAuth authentication, you can pass a `LoginPage` element: @@ -277,11 +372,20 @@ export const MyAdmin = () => ( ``` Make sure you enabled the specified providers in your Supabase instance: -- [Hosted instance](https://supabase.com/docs/guides/auth/social-login) -- [Local instance](https://supabase.com/docs/reference/cli/config#auth.external.provider.enabled) + +- [Hosted instance](https://supabase.com/docs/guides/auth/social-login) +- [Local instance](https://supabase.com/docs/reference/cli/config#auth.external.provider.enabled) This also requires you to configure the redirect URLS on your supabase instance: +##### Configuring a local Supabase instance + +1. Go to your `config.toml` file +2. In `[auth]` section set `site_url` to your application URL +3. In `[auth]`, add the following URL in the `additional_redirect_urls = [{APPLICATION_URL}}/auth-callback"]` + +#### Configuring an hosted Supabase instance + 1. Go to your dashboard **Authentication** section 1. In **URL Configuration**, set **Site URL** to your application URL 1. In **URL Configuration**, add the following URL in the **Redirect URLs** section: `YOUR_APPLICATION_URL/auth-callback` @@ -298,7 +402,9 @@ export const MyAdmin = () => ( } + loginPage={ + + } > @@ -306,6 +412,8 @@ export const MyAdmin = () => ( ); ``` +For HashRouter see [Using Hash Router](#using-hash-router). + ## Internationalization Support We provide two language packages: