Skip to content

Commit

Permalink
feat: onUserLoaded prop in UserProvider (#24)
Browse files Browse the repository at this point in the history
* feat: onUserLoaded in UserProvider

* chore: getAccessToken auto refresh

* chore: getUser refactor

* chore: rename to onUserLoadedData

* chore: docs n changelog
  • Loading branch information
thorwebdev authored Feb 17, 2022
1 parent f8937cf commit 3ba7de5
Show file tree
Hide file tree
Showing 10 changed files with 153 additions and 36 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 12 additions & 2 deletions examples/nextjs/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<UserProvider supabaseClient={supabaseClient}>
<UserProvider
supabaseClient={supabaseClient}
onUserLoaded={async (supabaseClient) =>
(await supabaseClient.from('test').select('*').single()).data
}
>
<Component {...pageProps} />
</UserProvider>
);
Expand Down
20 changes: 7 additions & 13 deletions examples/nextjs/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -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<any>({});

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 (
Expand All @@ -32,11 +22,15 @@ const LoginPage: NextPage = () => {

return (
<>
<p>
[<Link href="/profile">withAuthRequired</Link>] | [
<Link href="/protected-page">supabaseServerClient</Link>]
</p>
<button onClick={() => supabaseClient.auth.signOut()}>Sign out</button>
<p>user:</p>
<pre>{JSON.stringify(user, null, 2)}</pre>
<p>client-side data fetching with RLS</p>
<pre>{JSON.stringify(data, null, 2)}</pre>
<pre>{JSON.stringify(onUserLoadedData, null, 2)}</pre>
</>
);
};
Expand Down
8 changes: 8 additions & 0 deletions examples/nextjs/pages/profile.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<p>
[<Link href="/">Home</Link>] | [
<Link href="/protected-page">supabaseServerClient</Link>]
</p>
<div>Hello {user.email}</div>
<pre>{JSON.stringify(user, null, 2)}</pre>
<pre>{JSON.stringify(onUserLoadedData, null, 2)}</pre>
</>
);
}
Expand Down
5 changes: 5 additions & 0 deletions examples/nextjs/pages/protected-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
withAuthRequired,
supabaseServerClient
} from '@supabase/supabase-auth-helpers/nextjs';
import Link from 'next/link';

export default function ProtectedPage({
user,
Expand All @@ -14,6 +15,10 @@ export default function ProtectedPage({
}) {
return (
<>
<p>
[<Link href="/">Home</Link>] | [
<Link href="/profile">withAuthRequired</Link>]
</p>
<div>Protected content for {user.email}</div>
<p>server-side fetched data with RLS:</p>
<pre>{JSON.stringify(data, null, 2)}</pre>
Expand Down
7 changes: 7 additions & 0 deletions src/README.md
Original file line number Diff line number Diff line change
@@ -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)
32 changes: 32 additions & 0 deletions src/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<UserProvider
supabaseClient={supabaseClient}
onUserLoaded={async (supabaseClient) =>
(await supabaseClient.from('users').select('*').single()).data
}
>
<Component {...pageProps} />
</UserProvider>
);
}

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:
Expand Down
16 changes: 15 additions & 1 deletion src/nextjs/utils/getAccessToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;
}
}
36 changes: 22 additions & 14 deletions src/nextjs/utils/getUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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 };
}
}
45 changes: 39 additions & 6 deletions src/react/components/UserProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserState>({
user: null,
isLoading: false
onUserLoadedData: null,
accessToken: null,
isLoading: true
});

type UserFetcher = (
Expand All @@ -32,6 +37,7 @@ export interface Props {
profileUrl?: string;
user?: User;
fetcher?: UserFetcher;
onUserLoaded?: (supabaseClient: SupabaseClient) => Promise<any>;
[propName: string]: any;
}

Expand All @@ -41,31 +47,56 @@ 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<User | null>(initialUser);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(!initialUser);
const [error, setError] = useState<Error>();
const [onUserLoadedData, setOnUserLoadedData] = useState<any>(null);
const { pathname } = useRouter();

const checkSession = useCallback(async (): Promise<void> => {
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`);
setError(error);
}
}, [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(
Expand Down Expand Up @@ -99,6 +130,8 @@ export const UserProvider = (props: Props) => {
const value = {
isLoading,
user,
onUserLoadedData,
accessToken,
error
};
return <UserContext.Provider value={value} {...props} />;
Expand Down

0 comments on commit 3ba7de5

Please sign in to comment.