diff --git a/EXAMPLES.md b/EXAMPLES.md index 73037ca95..c5fa77980 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -61,9 +61,10 @@ Check the user's authentication state and log them in or out from the front end import { useUser } from '@auth0/nextjs-auth0'; export default () => { - const { user, isLoading } = useUser(); + const { user, error, isLoading } = useUser(); if (isLoading) return
Loading...
; + if (error) return
{error.message}
; if (user) { return ( diff --git a/README.md b/README.md index eb986206f..12c7771e9 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,10 @@ Check whether a user is authenticated by checking that `user` has a value, and l import { useUser } from '@auth0/nextjs-auth0'; export default () => { - const { user, isLoading } = useUser(); + const { user, error, isLoading } = useUser(); if (isLoading) return
Loading...
; + if (error) return
{error.message}
; if (user) { return ( diff --git a/examples/basic-example/pages/index.jsx b/examples/basic-example/pages/index.jsx index e3940e651..fc61c5ab0 100644 --- a/examples/basic-example/pages/index.jsx +++ b/examples/basic-example/pages/index.jsx @@ -4,7 +4,7 @@ import { useUser } from '@auth0/nextjs-auth0'; import Layout from '../components/layout'; export default function Home() { - const { user, isLoading } = useUser(); + const { user, error, isLoading } = useUser(); return ( @@ -12,14 +12,10 @@ export default function Home() { {isLoading &&

Loading login info...

} - {!isLoading && !user && ( + {error && ( <> -

- To test the login click in Login -

-

- Once you have logged in you should be able to click in Protected Page and Logout -

+

Error

+
{error.message}
)} @@ -29,6 +25,17 @@ export default function Home() {
{JSON.stringify(user, null, 2)}
)} + + {!isLoading && !error && !user && ( + <> +

+ To test the login click in Login +

+

+ Once you have logged in you should be able to click in Protected Page and Logout +

+ + )}
); } diff --git a/examples/basic-example/pages/protected-page.jsx b/examples/basic-example/pages/protected-page.jsx index 1b50251b7..e2a056a83 100644 --- a/examples/basic-example/pages/protected-page.jsx +++ b/examples/basic-example/pages/protected-page.jsx @@ -4,7 +4,7 @@ import { useUser, withPageAuthRequired } from '@auth0/nextjs-auth0'; import Layout from '../components/layout'; export default function ProtectedPage() { - const { user, isLoading } = useUser(); + const { user, error, isLoading } = useUser(); return ( @@ -12,9 +12,16 @@ export default function ProtectedPage() { {isLoading &&

Loading profile...

} - {!isLoading && user && ( + {error && ( <> -

Profile:

+

Error

+
{error.message}
+ + )} + + {user && ( + <> +

Profile

{JSON.stringify(user, null, 2)}
)} diff --git a/examples/kitchen-sink-example/pages/index.tsx b/examples/kitchen-sink-example/pages/index.tsx index eee707566..960500188 100644 --- a/examples/kitchen-sink-example/pages/index.tsx +++ b/examples/kitchen-sink-example/pages/index.tsx @@ -4,7 +4,7 @@ import { useUser } from '@auth0/nextjs-auth0'; import Layout from '../components/layout'; export default function Home(): React.ReactElement { - const { user, isLoading } = useUser(); + const { user, error, isLoading } = useUser(); return ( @@ -12,14 +12,10 @@ export default function Home(): React.ReactElement { {isLoading &&

Loading login info...

} - {!isLoading && !user && ( + {error && ( <> -

- To test the login click in Login -

-

- Once you have logged in you should be able to click in Profile and Logout -

+

Error

+
{error.message}
)} @@ -29,6 +25,17 @@ export default function Home(): React.ReactElement {
{JSON.stringify(user, null, 2)}
)} + + {!isLoading && !error && !user && ( + <> +

+ To test the login click in Login +

+

+ Once you have logged in you should be able to click in Protected Page and Logout +

+ + )}
); } diff --git a/examples/kitchen-sink-example/pages/profile.tsx b/examples/kitchen-sink-example/pages/profile.tsx index d01966d6f..bb63bfa30 100644 --- a/examples/kitchen-sink-example/pages/profile.tsx +++ b/examples/kitchen-sink-example/pages/profile.tsx @@ -4,7 +4,7 @@ import { useUser, withPageAuthRequired } from '@auth0/nextjs-auth0'; import Layout from '../components/layout'; export default withPageAuthRequired(function Profile(): React.ReactElement { - const { user, isLoading } = useUser(); + const { user, error, isLoading } = useUser(); return ( @@ -12,9 +12,16 @@ export default withPageAuthRequired(function Profile(): React.ReactElement { {isLoading &&

Loading profile...

} - {!isLoading && user && ( + {error && ( <> -

Profile:

+

Error

+
{error.message}
+ + )} + + {user && ( + <> +

Profile

{JSON.stringify(user, null, 2)}
)} diff --git a/src/frontend/index.ts b/src/frontend/index.ts index d960b171a..c652b9e4b 100644 --- a/src/frontend/index.ts +++ b/src/frontend/index.ts @@ -1,2 +1,2 @@ -export { default as UserProvider, UserProfile, UserContext, useUser } from './use-user'; +export { default as UserProvider, UserProviderProps, UserProfile, UserContext, useUser } from './use-user'; export { default as withPageAuthRequired, WithPageAuthRequired } from './with-page-auth-required'; diff --git a/src/frontend/use-user.tsx b/src/frontend/use-user.tsx index fdb6bb82e..02965a53b 100644 --- a/src/frontend/use-user.tsx +++ b/src/frontend/use-user.tsx @@ -23,6 +23,7 @@ export interface UserProfile { */ export interface UserContext { user?: UserProfile; + error?: Error; isLoading: boolean; } @@ -41,7 +42,7 @@ export interface UserContext { * export default function App({ Component, pageProps }) { * // If you've used `withAuth`, pageProps.user can pre-populate the hook * // if you haven't used `withAuth`, pageProps.user is undefined so the hook - * // fetches the user from the API routes + * // fetches the user from the API route * const { user } = pageProps; * * return ( @@ -57,7 +58,7 @@ export interface UserContext { * * @category Client */ -type UserProviderProps = React.PropsWithChildren<{ user?: UserProfile; profileUrl?: string }>; +export type UserProviderProps = React.PropsWithChildren<{ user?: UserProfile; profileUrl?: string }>; /** * @ignore @@ -74,9 +75,10 @@ const User = createContext({ isLoading: false }); * import { useUser } from '@auth0/nextjs-auth0`; * * export default function Profile() { - * const { user, isLoading } = useUser(); + * const { user, error, isLoading } = useUser(); * * if (isLoading) return
Loading...
; + * if (error) return
{error.message}
; * if (!user) return Login; * return
Hello {user.name}, Logout
; * } @@ -104,19 +106,24 @@ export default ({ profileUrl = '/api/auth/me' }: UserProviderProps): ReactElement => { const [user, setUser] = useState(() => initialUser); + const [error, setError] = useState(); const [isLoading, setIsLoading] = useState(() => !initialUser); useEffect((): void => { if (user) return; (async (): Promise => { - const response = await fetch(profileUrl); - const result = response.ok ? await response.json() : undefined; - - setUser(result); - setIsLoading(false); + try { + const response = await fetch(profileUrl); + setUser(response.ok ? await response.json() : undefined); + setError(undefined); + } catch (_e) { + setError(new Error(`The request to ${profileUrl} failed`)); + } finally { + setIsLoading(false); + } })(); }, [user]); - return {children}; + return {children}; }; diff --git a/src/frontend/with-page-auth-required.tsx b/src/frontend/with-page-auth-required.tsx index e2b9b6052..3b20ab301 100644 --- a/src/frontend/with-page-auth-required.tsx +++ b/src/frontend/with-page-auth-required.tsx @@ -8,6 +8,11 @@ import { useUser } from './use-user'; */ const defaultOnRedirecting = (): JSX.Element => <>; +/** + * @ignore + */ +const defaultOnError = (): JSX.Element => <>; + /** * Options for the withPageAuthRequired Higher Order Component * @@ -43,6 +48,16 @@ export interface WithPageAuthRequiredOptions { * Render a message to show that the user is being redirected to the login. */ onRedirecting?: () => JSX.Element; + /** + * ```js + * withPageAuthRequired(Profile, { + * onError: error =>
Error: {error.message}
+ * }); + * ``` + * + * Render a fallback in case of error fetching the user from the profile API route. + */ + onError?: (error: Error) => JSX.Element; } /** @@ -66,18 +81,26 @@ export type WithPageAuthRequired =

( const withPageAuthRequired: WithPageAuthRequired = (Component, options = {}) => { return function withPageAuthRequired(props): JSX.Element { const router = useRouter(); - const { returnTo = router.asPath, onRedirecting = defaultOnRedirecting, loginUrl = '/api/auth/login' } = options; - const { user, isLoading } = useUser(); + const { + returnTo = router.asPath, + onRedirecting = defaultOnRedirecting, + onError = defaultOnError, + loginUrl = '/api/auth/login' + } = options; + const { user, error, isLoading } = useUser(); useEffect(() => { - if (user || isLoading) return; + if ((user && !error) || isLoading) return; (async (): Promise => { await router.push(`${loginUrl}?returnTo=${returnTo}`); })(); - }, [user, isLoading, router, loginUrl, returnTo]); + }, [user, error, isLoading]); + + if (error) return onError(error); + if (user) return ; - return user ? : onRedirecting(); + return onRedirecting(); }; }; diff --git a/src/index.browser.ts b/src/index.browser.ts index 04eb734bc..d8de45732 100644 --- a/src/index.browser.ts +++ b/src/index.browser.ts @@ -4,6 +4,7 @@ import { WithApiAuthRequired } from './helpers'; import { HandleAuth, HandleCallback, HandleLogin, HandleLogout, HandleProfile } from './handlers'; export { UserProvider, + UserProviderProps, UserProfile, UserContext, useUser, diff --git a/src/index.ts b/src/index.ts index 30bb77987..106f3fd6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,7 +92,7 @@ export const handleCallback: HandleCallback = (...args) => getInstance().handleC export const handleProfile: HandleProfile = (...args) => getInstance().handleProfile(...args); export const handleAuth: HandleAuth = (...args) => getInstance().handleAuth(...args); -export { UserProvider, UserProfile, UserContext, useUser } from './frontend'; +export { UserProvider, UserProviderProps, UserProfile, UserContext, useUser } from './frontend'; export { Config, diff --git a/tests/fixtures/frontend.tsx b/tests/fixtures/frontend.tsx index 617bcc6db..688771a71 100644 --- a/tests/fixtures/frontend.tsx +++ b/tests/fixtures/frontend.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { UserProfile, UserProvider } from '../../src'; +import { UserProvider, UserProviderProps, UserProfile } from '../../src'; type FetchUserMock = { ok: boolean; @@ -16,16 +16,21 @@ export const user: UserProfile = { updated_at: null }; -export const withUser = (user?: UserProfile): React.ComponentType => { - return (props: any): React.ReactElement => ; +export const withUserProvider = ({ user, profileUrl }: UserProviderProps = {}): React.ComponentType => { + return (props: any): React.ReactElement => ; }; -export const fetchUserMock = (): FetchUserMock => ({ - ok: true, - json: (): Promise => Promise.resolve(user) -}); +export const fetchUserMock = (): Promise => { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(user) + }); +}; + +export const fetchUserUnsuccessfulMock = (): Promise => { + return Promise.resolve({ + ok: false + }); +}; -export const fetchUserFailureMock = (): FetchUserMock => ({ - ok: false, - json: undefined -}); +export const fetchUserErrorMock = (): Promise => Promise.reject(new Error('Error')); diff --git a/tests/frontend/use-user.test.tsx b/tests/frontend/use-user.test.tsx index 618170394..51e1fe8ea 100644 --- a/tests/frontend/use-user.test.tsx +++ b/tests/frontend/use-user.test.tsx @@ -1,44 +1,79 @@ import { renderHook } from '@testing-library/react-hooks'; import { useUser } from '../../src'; -import { fetchUserMock, fetchUserFailureMock, withUser, user } from '../fixtures/frontend'; +import { + fetchUserMock, + fetchUserUnsuccessfulMock, + fetchUserErrorMock, + withUserProvider, + user +} from '../fixtures/frontend'; describe('context wrapper', () => { - test('should use the initial user', async () => { - const { result } = renderHook(() => useUser(), { wrapper: withUser(user) }); + test('should fetch the user', async () => { + (global as any).fetch = fetchUserMock; + const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() }); + + expect(result.current.user).toEqual(undefined); + expect(result.current.error).toEqual(undefined); + expect(result.current.isLoading).toEqual(true); + + await waitForValueToChange(() => result.current.isLoading); expect(result.current.user).toEqual(user); + expect(result.current.error).toEqual(undefined); expect(result.current.isLoading).toEqual(false); }); - test('should fetch the user', async () => { - (global as any).fetch = fetchUserMock; - - const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUser() }); + test('should discard the response when the status code is not successful', async () => { + (global as any).fetch = fetchUserUnsuccessfulMock; + const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() }); expect(result.current.user).toEqual(undefined); + expect(result.current.error).toEqual(undefined); expect(result.current.isLoading).toEqual(true); await waitForValueToChange(() => result.current.isLoading); - expect(result.current.user).toEqual(user); + expect(result.current.user).toEqual(undefined); + expect(result.current.error).toEqual(undefined); expect(result.current.isLoading).toEqual(false); }); test('should fail to fetch the user', async () => { - (global as any).fetch = fetchUserFailureMock; - - const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUser() }); + (global as any).fetch = fetchUserErrorMock; + const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() }); expect(result.current.user).toEqual(undefined); + expect(result.current.error).toEqual(undefined); expect(result.current.isLoading).toEqual(true); await waitForValueToChange(() => result.current.isLoading); expect(result.current.user).toEqual(undefined); + expect(result.current.error).toEqual(new Error('The request to /api/auth/me failed')); expect(result.current.isLoading).toEqual(false); }); + test('should use the existing user', async () => { + const { result } = renderHook(() => useUser(), { wrapper: withUserProvider({ user }) }); + + expect(result.current.user).toEqual(user); + expect(result.current.error).toEqual(undefined); + expect(result.current.isLoading).toEqual(false); + }); + + test('should use a custom profileUrl', async () => { + const fetchSpy = jest.fn(); + (global as any).fetch = fetchSpy; + const { result, waitForValueToChange } = renderHook(() => useUser(), { + wrapper: withUserProvider({ profileUrl: '/api/custom-url' }) + }); + + await waitForValueToChange(() => result.current.isLoading); + expect(fetchSpy).toHaveBeenCalledWith('/api/custom-url'); + }); + afterAll(() => { delete (global as any).fetch; }); diff --git a/tests/frontend/with-page-auth-required.test.tsx b/tests/frontend/with-page-auth-required.test.tsx index d1b135430..7c1a4f8b7 100644 --- a/tests/frontend/with-page-auth-required.test.tsx +++ b/tests/frontend/with-page-auth-required.test.tsx @@ -5,7 +5,7 @@ import '@testing-library/jest-dom/extend-expect'; import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; -import { fetchUserFailureMock, withUser, user } from '../fixtures/frontend'; +import { fetchUserUnsuccessfulMock, fetchUserErrorMock, withUserProvider, user } from '../fixtures/frontend'; import { withPageAuthRequired } from '../../src/frontend'; const routerMock = { @@ -17,11 +17,11 @@ jest.mock('next/router', () => ({ useRouter: (): any => routerMock })); describe('with-page-auth-required csr', () => { it('should block access to a CSR page when not authenticated', async () => { - (global as any).fetch = fetchUserFailureMock; + (global as any).fetch = fetchUserUnsuccessfulMock; const MyPage = (): JSX.Element => <>Private; const ProtectedPage = withPageAuthRequired(MyPage); - render(, { wrapper: withUser() }); + render(, { wrapper: withUserProvider() }); await waitFor(() => expect(routerMock.push).toHaveBeenCalledTimes(1)); await waitFor(() => expect(screen.queryByText('Private')).not.toBeInTheDocument()); }); @@ -30,7 +30,7 @@ describe('with-page-auth-required csr', () => { const MyPage = (): JSX.Element => <>Private; const ProtectedPage = withPageAuthRequired(MyPage); - render(, { wrapper: withUser(user) }); + render(, { wrapper: withUserProvider({ user }) }); await waitFor(() => expect(routerMock.push).not.toHaveBeenCalled()); await waitFor(() => expect(screen.getByText('Private')).toBeInTheDocument()); }); @@ -40,15 +40,25 @@ describe('with-page-auth-required csr', () => { const OnRedirecting = (): JSX.Element => <>Redirecting; const ProtectedPage = withPageAuthRequired(MyPage, { onRedirecting: OnRedirecting }); - render(, { wrapper: withUser() }); + render(, { wrapper: withUserProvider() }); await waitFor(() => expect(screen.getByText('Redirecting')).toBeInTheDocument()); }); + it('should show a fallback in case of error', async () => { + (global as any).fetch = fetchUserErrorMock; + const MyPage = (): JSX.Element => <>Private; + const OnError = (): JSX.Element => <>Error; + const ProtectedPage = withPageAuthRequired(MyPage, { onError: OnError }); + + render(, { wrapper: withUserProvider() }); + await waitFor(() => expect(screen.getByText('Error')).toBeInTheDocument()); + }); + it('should accept a returnTo url', async () => { const MyPage = (): JSX.Element => <>Private; const ProtectedPage = withPageAuthRequired(MyPage, { returnTo: '/foo' }); - render(, { wrapper: withUser() }); + render(, { wrapper: withUserProvider() }); await waitFor(() => expect(routerMock.push).toHaveBeenCalledWith(expect.stringContaining('?returnTo=/foo'))); });