diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ec1b3f2f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# CHANGELOG + +## 1.0.7 - 2022-02-17 + +- [#24](https://github.com/supabase-community/supabase-auth-helpers/pull/24): feat: onUserLoaded prop in UserProvider: + - Added the `onUserDataLoaded` on the `UserProvider` component fro conveninet fetching of additional user data. See [the docs](./src/nextjs/README.md#loading-additional-user-data) for more details. diff --git a/examples/nextjs/pages/_app.tsx b/examples/nextjs/pages/_app.tsx index d03c813d..5c350912 100644 --- a/examples/nextjs/pages/_app.tsx +++ b/examples/nextjs/pages/_app.tsx @@ -1,11 +1,21 @@ import '../styles/globals.css'; import type { AppProps } from 'next/app'; import { UserProvider } from '@supabase/supabase-auth-helpers/react'; -import { supabaseClient } from '@supabase/supabase-auth-helpers/nextjs'; +import { + supabaseClient, + SupabaseClient +} from '@supabase/supabase-auth-helpers/nextjs'; +// You can pass an onUserLoaded method to fetch additional data from your public scema. +// This data will be available as the `onUserLoadedData` prop in the `useUser` hook. function MyApp({ Component, pageProps }: AppProps) { return ( - + + (await supabaseClient.from('test').select('*').single()).data + } + > ); diff --git a/examples/nextjs/pages/index.tsx b/examples/nextjs/pages/index.tsx index 6b5f38af..5919d27d 100644 --- a/examples/nextjs/pages/index.tsx +++ b/examples/nextjs/pages/index.tsx @@ -1,20 +1,10 @@ import { useUser, Auth } from '@supabase/supabase-auth-helpers/react'; import { supabaseClient } from '@supabase/supabase-auth-helpers/nextjs'; import type { NextPage } from 'next'; -import { useEffect, useState } from 'react'; +import Link from 'next/link'; const LoginPage: NextPage = () => { - const { user, error } = useUser(); - const [data, setData] = useState({}); - - useEffect(() => { - async function loadData() { - const { data } = await supabaseClient.from('test').select('*'); - setData(data); - } - // Only run query once user is logged in. - if (user) loadData(); - }, [user]); + const { user, onUserLoadedData, error } = useUser(); if (!user) return ( @@ -32,11 +22,15 @@ const LoginPage: NextPage = () => { return ( <> +

+ [withAuthRequired] | [ + supabaseServerClient] +

user:

{JSON.stringify(user, null, 2)}

client-side data fetching with RLS

-
{JSON.stringify(data, null, 2)}
+
{JSON.stringify(onUserLoadedData, null, 2)}
); }; diff --git a/examples/nextjs/pages/profile.tsx b/examples/nextjs/pages/profile.tsx index 230fd355..28ceba6d 100644 --- a/examples/nextjs/pages/profile.tsx +++ b/examples/nextjs/pages/profile.tsx @@ -1,11 +1,19 @@ // pages/profile.js import { withAuthRequired, User } from '@supabase/supabase-auth-helpers/nextjs'; +import { useUser } from '@supabase/supabase-auth-helpers/react'; +import Link from 'next/link'; export default function Profile({ user }: { user: User }) { + const { onUserLoadedData } = useUser(); return ( <> +

+ [Home] | [ + supabaseServerClient] +

Hello {user.email}
{JSON.stringify(user, null, 2)}
+
{JSON.stringify(onUserLoadedData, null, 2)}
); } diff --git a/examples/nextjs/pages/protected-page.tsx b/examples/nextjs/pages/protected-page.tsx index 43e751eb..b4d11456 100644 --- a/examples/nextjs/pages/protected-page.tsx +++ b/examples/nextjs/pages/protected-page.tsx @@ -4,6 +4,7 @@ import { withAuthRequired, supabaseServerClient } from '@supabase/supabase-auth-helpers/nextjs'; +import Link from 'next/link'; export default function ProtectedPage({ user, @@ -14,6 +15,10 @@ export default function ProtectedPage({ }) { return ( <> +

+ [Home] | [ + withAuthRequired] +

Protected content for {user.email}

server-side fetched data with RLS:

{JSON.stringify(data, null, 2)}
diff --git a/src/README.md b/src/README.md new file mode 100644 index 00000000..8fcffdd4 --- /dev/null +++ b/src/README.md @@ -0,0 +1,7 @@ +# @supabase/supabase-auth-helpers + +A collection of framework specific Auth utilities for working with Supabase. + +## Supported Frameworks + +- [Next.js](./nextjs/README.md) diff --git a/src/nextjs/README.md b/src/nextjs/README.md index 52173333..f1b8d0d0 100644 --- a/src/nextjs/README.md +++ b/src/nextjs/README.md @@ -67,6 +67,38 @@ export default function App({ Component, pageProps }) { You can now determine if a user is authenticated by checking that the `user` object returned by the `useUser()` hook is defined. +## Loading additional user data + +The `user` object from the `useUser()` hook is only meant to be used as an indicator that the user is signed in, you're not meant to store additional user information in this object but rather you're meant to store additional information in your `public.users` table. See [the "Managing User Data" docs](https://supabase.com/docs/guides/auth/managing-user-data) for more details on this. + +You can conveniently make your additional user data available in the `useUser()` hook but setting the `onUserDataLoaded` prop on the `UserProvider` components: + +```js +import type { AppProps } from 'next/app'; +import { UserProvider } from '@supabase/supabase-auth-helpers/react'; +import { + supabaseClient, + SupabaseClient +} from '@supabase/supabase-auth-helpers/nextjs'; + +// You can pass an onUserLoaded method to fetch additional data from your public schema. +// This data will be available as the `onUserLoadedData` prop in the `useUser` hook. +function MyApp({ Component, pageProps }: AppProps) { + return ( + + (await supabaseClient.from('users').select('*').single()).data + } + > + + + ); +} + +export default MyApp; +``` + ## Client-side data fetching with RLS For [row level security](https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security) to work properly when fetching data client-side, you need to make sure to import the `{ supabaseClient }` from `# @supabase/supabase-auth-helpers/nextjs` and only run your query once the user is defined client-side in the `useUser()` hook: diff --git a/src/nextjs/utils/getAccessToken.ts b/src/nextjs/utils/getAccessToken.ts index 19af5a65..dbb0ba86 100644 --- a/src/nextjs/utils/getAccessToken.ts +++ b/src/nextjs/utils/getAccessToken.ts @@ -3,6 +3,8 @@ import { NextApiRequest, NextApiResponse } from 'next'; +import getUser from './getUser'; +import { jwtDecoder } from '../../shared/utils/jwt'; import { CookieOptions } from '../types'; export default async function getAccessToken( @@ -23,5 +25,17 @@ export default async function getAccessToken( throw new Error('No cookie found!'); } - return access_token; + // Get payload from access token. + const jwtUser = jwtDecoder(access_token); + if (!jwtUser?.exp) { + throw new Error('Not able to parse JWT payload!'); + } + const timeNow = Math.round(Date.now() / 1000); + if (jwtUser.exp < timeNow) { + // JWT is expired, let's refresh from Gotrue + const { accessToken } = await getUser(context, cookieOptions); + return accessToken; + } else { + return access_token; + } } diff --git a/src/nextjs/utils/getUser.ts b/src/nextjs/utils/getUser.ts index bf632a97..f59744bc 100644 --- a/src/nextjs/utils/getUser.ts +++ b/src/nextjs/utils/getUser.ts @@ -7,6 +7,7 @@ import { User, createClient } from '@supabase/supabase-js'; import { CookieOptions } from '../types'; import { setCookies } from '../../shared/utils/cookies'; import { COOKIE_OPTIONS } from './constants'; +import { jwtDecoder } from '../../shared/utils/jwt'; export default async function getUser( context: @@ -38,28 +39,27 @@ export default async function getUser( if (!access_token) { throw new Error('No cookie found!'); } - - const { user, error: getUserError } = await supabase.auth.api.getUser( - access_token - ); - if (getUserError) { + // Get payload from access token. + const jwtUser = jwtDecoder(access_token); + if (!jwtUser?.exp) { + throw new Error('Not able to parse JWT payload!'); + } + const timeNow = Math.round(Date.now() / 1000); + if (jwtUser.exp < timeNow) { + // JWT is expired, let's refresh from Gotrue if (!refresh_token) throw new Error('No refresh_token cookie found!'); - if (!context.res) - throw new Error( - 'You need to pass the res object to automatically refresh the session!' - ); const { data, error } = await supabase.auth.api.refreshAccessToken( refresh_token ); if (error) { throw error; - } else if (data) { + } else { setCookies( context.req, context.res, [ - { key: 'access-token', value: data.access_token }, - { key: 'refresh-token', value: data.refresh_token! } + { key: 'access-token', value: data!.access_token }, + { key: 'refresh-token', value: data!.refresh_token! } ].map((token) => ({ name: `${cookieOptions.name}-${token.key}`, value: token.value, @@ -69,11 +69,19 @@ export default async function getUser( sameSite: cookieOptions.sameSite })) ); - return { user: data.user!, accessToken: data.access_token }; + return { user: data!.user!, accessToken: data!.access_token }; + } + } else { + const { user, error: getUserError } = await supabase.auth.api.getUser( + access_token + ); + if (getUserError) { + throw getUserError; } + return { user: user!, accessToken: access_token }; } - return { user: user!, accessToken: access_token }; } catch (e) { + console.log('Error getting user:', e); return { user: null, accessToken: null }; } } diff --git a/src/react/components/UserProvider.tsx b/src/react/components/UserProvider.tsx index 088bc456..b5c57221 100644 --- a/src/react/components/UserProvider.tsx +++ b/src/react/components/UserProvider.tsx @@ -5,17 +5,22 @@ import React, { useContext, useCallback } from 'react'; +import { useRouter } from 'next/router'; import { SupabaseClient, User } from '@supabase/supabase-js'; export type UserState = { user: User | null; + onUserLoadedData: any | null; + accessToken: string | null; error?: Error; isLoading: boolean; }; const UserContext = createContext({ user: null, - isLoading: false + onUserLoadedData: null, + accessToken: null, + isLoading: true }); type UserFetcher = ( @@ -32,6 +37,7 @@ export interface Props { profileUrl?: string; user?: User; fetcher?: UserFetcher; + onUserLoaded?: (supabaseClient: SupabaseClient) => Promise; [propName: string]: any; } @@ -41,16 +47,23 @@ export const UserProvider = (props: Props) => { callbackUrl = '/api/auth/callback', profileUrl = '/api/auth/user', user: initialUser = null, - fetcher = userFetcher + fetcher = userFetcher, + onUserLoaded } = props; const [user, setUser] = useState(initialUser); + const [accessToken, setAccessToken] = useState(null); const [isLoading, setIsLoading] = useState(!initialUser); const [error, setError] = useState(); + const [onUserLoadedData, setOnUserLoadedData] = useState(null); + const { pathname } = useRouter(); const checkSession = useCallback(async (): Promise => { try { const { user, accessToken } = await fetcher(profileUrl); - if (accessToken) supabaseClient.auth.setAuth(accessToken); + if (accessToken) { + supabaseClient.auth.setAuth(accessToken); + setAccessToken(accessToken); + } setUser(user); } catch (_e) { const error = new Error(`The request to ${profileUrl} failed`); @@ -58,14 +71,32 @@ export const UserProvider = (props: Props) => { } }, [profileUrl]); + // Get cached user on every page render. useEffect(() => { - async function init() { + console.log(pathname); + async function runOnPathChange() { setIsLoading(true); await checkSession(); setIsLoading(false); } - init(); - }, []); + runOnPathChange(); + }, [pathname]); + + // Only load user Data when the access token changes. + useEffect(() => { + async function loadUserData() { + console.log(onUserLoaded, !onUserLoadedData, onUserLoadedData); + if (onUserLoaded && !onUserLoadedData) { + try { + const response = await onUserLoaded(supabaseClient); + setOnUserLoadedData(response); + } catch (error) { + console.log('Error in your `onUserLoaded` method:', error); + } + } + } + if (user) loadUserData(); + }, [user, accessToken]); useEffect(() => { const { data: authListener } = supabaseClient.auth.onAuthStateChange( @@ -99,6 +130,8 @@ export const UserProvider = (props: Props) => { const value = { isLoading, user, + onUserLoadedData, + accessToken, error }; return ;