Repository: auth0/nextjs-auth0
Pull request #262 (2021-01-29 01:58:01)

Added internal useConfig hook [SDK-2264]
### Description

If the developer set up their login handler URL to be something other than the default `/api/auth/login`, then they would have to pass the custom value on every call to `withPageAuthRequired`. 

This PR moves the configuration of custom URLs out of `withPageAuthRequired` to the `UserProvider`. Plus it's now also possible to set it with an environment variable that is accessible from the client-side, enabling the developer to just set it once in a single place. This results in an improved developer experience.

Also, it's possible to customize the profile handler URL via an environment variable as well, saving the developer from having to pass it to the `UserProvider`.

### Testing

- [X] This change adds test coverage for new/changed/fixed functionality

### Checklist

- [X] I have added documentation for new/changed functionality in this PR or in auth0.com/docs
- [X] All active GitHub checks for tests, formatting, and security are passing
- [X] The correct base branch is being used, if not `master`


vercel[bot]: This pull request is being automatically deployed with Vercel ([learn more](https://vercel.link/github-learn-more)).  
To see the status of your deployment, click below or on the icon next to each commit.

🔍 Inspect: [https://vercel.com/auth0/nextjs-auth0/9pt9xgt5u](https://vercel.com/auth0/nextjs-auth0/9pt9xgt5u)  
✅ Preview: *Canceled*



Patch:
diff --git a/src/frontend/index.ts b/src/frontend/index.ts
index c652b9e4..82ab7529 100644
--- a/src/frontend/index.ts
+++ b/src/frontend/index.ts
@@ -1,2 +1,3 @@
+export { default as ConfigProvider, ConfigProviderProps, useConfig } from './use-config';
 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-config.tsx b/src/frontend/use-config.tsx
new file mode 100644
index 00000000..b4e0a027
--- /dev/null
+++ b/src/frontend/use-config.tsx
@@ -0,0 +1,18 @@
+import React, { ReactElement, useContext, createContext } from 'react';
+
+export type ConfigContext = {
+  loginUrl?: string;
+};
+
+const Config = createContext<ConfigContext>({});
+
+export type ConfigProviderProps = React.PropsWithChildren<ConfigContext>;
+export type UseConfig = () => ConfigContext;
+export const useConfig: UseConfig = () => useContext<ConfigContext>(Config);
+
+export default ({
+  children,
+  loginUrl = process.env.NEXT_PUBLIC_AUTH0_LOGIN || '/api/auth/login'
+}: ConfigProviderProps): ReactElement<ConfigContext> => {
+  return <Config.Provider value={{ loginUrl }}>{children}</Config.Provider>;
+};
diff --git a/src/frontend/use-user.tsx b/src/frontend/use-user.tsx
index dc5e392d..c56d1d86 100644
--- a/src/frontend/use-user.tsx
+++ b/src/frontend/use-user.tsx
@@ -1,5 +1,7 @@
 import React, { ReactElement, useState, useEffect, useCallback, useContext, createContext } from 'react';
 
+import ConfigProvider, { ConfigContext } from './use-config';
+
 /**
  * The user claims returned from the {@link useUser} hook.
  *
@@ -21,12 +23,12 @@ export interface UserProfile {
  *
  * @category Client
  */
-export interface UserContext {
+export type UserContext = {
   user?: UserProfile;
   error?: Error;
   isLoading: boolean;
   checkSession: () => Promise<void>;
-}
+};
 
 /**
  * Configure the {@link UserProvider} component.
@@ -59,7 +61,7 @@ export interface UserContext {
  *
  * @category Client
  */
-export type UserProviderProps = React.PropsWithChildren<{ user?: UserProfile; profileUrl?: string }>;
+export type UserProviderProps = React.PropsWithChildren<{ user?: UserProfile; profileUrl?: string } & ConfigContext>;
 
 /**
  * @ignore
@@ -119,33 +121,47 @@ export const useUser: UseUser = () => useContext<UserContext>(User);
  */
 export type UserProvider = (props: UserProviderProps) => ReactElement<UserContext>;
 
+/**
+ * @ignore
+ */
+type UserProviderState = {
+  user?: UserProfile;
+  error?: Error;
+  isLoading: boolean;
+};
+
 export default ({
   children,
   user: initialUser,
-  profileUrl = '/api/auth/me'
+  profileUrl = process.env.NEXT_PUBLIC_AUTH0_PROFILE || '/api/auth/me',
+  loginUrl
 }: UserProviderProps): ReactElement<UserContext> => {
-  const [user, setUser] = useState<UserProfile | undefined>(() => initialUser);
-  const [error, setError] = useState<Error | undefined>();
-  const [isLoading, setIsLoading] = useState<boolean>(() => !initialUser);
+  const [state, setState] = useState<UserProviderState>({ user: initialUser, isLoading: !initialUser });
 
   const checkSession = useCallback(async (): Promise<void> => {
     try {
       const response = await fetch(profileUrl);
-      setUser(response.ok ? await response.json() : undefined);
-      setError(undefined);
+      const user = response.ok ? await response.json() : undefined;
+      setState((previous) => ({ ...previous, user, error: undefined }));
     } catch (_e) {
-      setUser(undefined);
-      setError(new Error(`The request to ${profileUrl} failed`));
+      const error = new Error(`The request to ${profileUrl} failed`);
+      setState((previous) => ({ ...previous, user: undefined, error }));
     }
   }, [profileUrl]);
 
   useEffect((): void => {
-    if (user) return;
+    if (state.user) return;
     (async (): Promise<void> => {
       await checkSession();
-      setIsLoading(false);
+      setState((previous) => ({ ...previous, isLoading: false }));
     })();
-  }, [user]);
+  }, [state.user]);
+
+  const { user, error, isLoading } = state;
 
-  return <User.Provider value={{ user, error, isLoading, checkSession }}>{children}</User.Provider>;
+  return (
+    <ConfigProvider loginUrl={loginUrl}>
+      <User.Provider value={{ user, error, isLoading, checkSession }}>{children}</User.Provider>
+    </ConfigProvider>
+  );
 };
diff --git a/src/frontend/with-page-auth-required.tsx b/src/frontend/with-page-auth-required.tsx
index 3b20ab30..59b79a55 100644
--- a/src/frontend/with-page-auth-required.tsx
+++ b/src/frontend/with-page-auth-required.tsx
@@ -1,6 +1,7 @@
 import React, { ComponentType, useEffect } from 'react';
 import { useRouter } from 'next/router';
 
+import { useConfig } from './use-config';
 import { useUser } from './use-user';
 
 /**
@@ -29,15 +30,6 @@ export interface WithPageAuthRequiredOptions {
    * Add a path to return the user to after login.
    */
   returnTo?: string;
-  /**
-   * ```js
-   * withPageAuthRequired(Profile, {
-   *   loginUrl: '/api/login'
-   * });
-   * ```
-   * The path of your custom login API route.
-   */
-  loginUrl?: string;
   /**
    * ```js
    * withPageAuthRequired(Profile, {
@@ -81,12 +73,8 @@ export type WithPageAuthRequired = <P extends object>(
 const withPageAuthRequired: WithPageAuthRequired = (Component, options = {}) => {
   return function withPageAuthRequired(props): JSX.Element {
     const router = useRouter();
-    const {
-      returnTo = router.asPath,
-      onRedirecting = defaultOnRedirecting,
-      onError = defaultOnError,
-      loginUrl = '/api/auth/login'
-    } = options;
+    const { returnTo = router.asPath, onRedirecting = defaultOnRedirecting, onError = defaultOnError } = options;
+    const { loginUrl } = useConfig();
     const { user, error, isLoading } = useUser();
 
     useEffect(() => {
diff --git a/typedoc.js b/typedoc.js
index 5247b5eb..34539b2a 100644
--- a/typedoc.js
+++ b/typedoc.js
@@ -4,6 +4,7 @@ module.exports = {
   exclude: [
     './src/auth0-session/**',
     './src/session/cache.ts',
+    './src/frontend/use-config.tsx',
     './src/utils/!(errors.ts)',
     './src/index.ts',
     './src/index.browser.ts'


Test patch:
diff --git a/tests/fixtures/frontend.tsx b/tests/fixtures/frontend.tsx
index 688771a7..5f0c1883 100644
--- a/tests/fixtures/frontend.tsx
+++ b/tests/fixtures/frontend.tsx
@@ -1,5 +1,7 @@
 import React from 'react';
+
 import { UserProvider, UserProviderProps, UserProfile } from '../../src';
+import { ConfigProvider, ConfigProviderProps } from '../../src/frontend';
 
 type FetchUserMock = {
   ok: boolean;
@@ -16,8 +18,10 @@ export const user: UserProfile = {
   updated_at: null
 };
 
-export const withUserProvider = ({ user, profileUrl }: UserProviderProps = {}): React.ComponentType => {
-  return (props: any): React.ReactElement => <UserProvider {...props} user={user} profileUrl={profileUrl} />;
+export const withUserProvider = ({ user, profileUrl, loginUrl }: UserProviderProps = {}): React.ComponentType => {
+  return (props: any): React.ReactElement => (
+    <UserProvider {...props} user={user} profileUrl={profileUrl} loginUrl={loginUrl} />
+  );
 };
 
 export const fetchUserMock = (): Promise<FetchUserMock> => {
@@ -34,3 +38,7 @@ export const fetchUserUnsuccessfulMock = (): Promise<FetchUserMock> => {
 };
 
 export const fetchUserErrorMock = (): Promise<FetchUserMock> => Promise.reject(new Error('Error'));
+
+export const withConfigProvider = ({ loginUrl }: ConfigProviderProps = {}): React.ComponentType => {
+  return (props: any): React.ReactElement => <ConfigProvider {...props} loginUrl={loginUrl} />;
+};
diff --git a/tests/frontend/use-config.test.ts b/tests/frontend/use-config.test.ts
new file mode 100644
index 00000000..e4969d50
--- /dev/null
+++ b/tests/frontend/use-config.test.ts
@@ -0,0 +1,36 @@
+import { renderHook } from '@testing-library/react-hooks';
+
+import { withConfigProvider } from '../fixtures/frontend';
+import { useConfig } from '../../src/frontend/use-config';
+
+jest.mock('next/router', () => ({
+  useRouter: (): any => ({ asPath: '/' })
+}));
+
+describe('context wrapper', () => {
+  test('should provide the default login url', async () => {
+    const { result } = renderHook(() => useConfig(), {
+      wrapper: withConfigProvider({ loginUrl: '/api/auth/login' })
+    });
+
+    expect(result.current.loginUrl).toEqual('/api/auth/login');
+  });
+
+  test('should provide a custom login url', async () => {
+    const { result } = renderHook(() => useConfig(), {
+      wrapper: withConfigProvider({ loginUrl: '/api/custom-url' })
+    });
+
+    expect(result.current.loginUrl).toEqual('/api/custom-url');
+  });
+
+  test('should provide a custom login url from an environment variable', async () => {
+    process.env.NEXT_PUBLIC_AUTH0_LOGIN = '/api/custom-url';
+    const { result } = renderHook(() => useConfig(), {
+      wrapper: withConfigProvider()
+    });
+
+    expect(result.current.loginUrl).toEqual('/api/custom-url');
+    delete process.env.NEXT_PUBLIC_AUTH0_LOGIN;
+  });
+});
diff --git a/tests/frontend/use-user.test.tsx b/tests/frontend/use-user.test.ts
similarity index 74%
rename from tests/frontend/use-user.test.tsx
rename to tests/frontend/use-user.test.ts
index 140740b9..9f34b2aa 100644
--- a/tests/frontend/use-user.test.tsx
+++ b/tests/frontend/use-user.test.ts
@@ -7,8 +7,13 @@ import {
   withUserProvider,
   user
 } from '../fixtures/frontend';
+import { useConfig } from '../../src/frontend';
 import { useUser } from '../../src';
 
+jest.mock('next/router', () => ({
+  useRouter: (): any => ({ asPath: '/' })
+}));
+
 describe('context wrapper', () => {
   afterEach(() => delete (global as any).fetch);
 
@@ -57,7 +62,7 @@ describe('context wrapper', () => {
     expect(result.current.isLoading).toEqual(false);
   });
 
-  test('should use the existing user', async () => {
+  test('should provide the existing user', async () => {
     const { result } = renderHook(() => useUser(), { wrapper: withUserProvider({ user }) });
 
     expect(result.current.user).toEqual(user);
@@ -65,7 +70,18 @@ describe('context wrapper', () => {
     expect(result.current.isLoading).toEqual(false);
   });
 
-  test('should use a custom profileUrl', async () => {
+  test('should use the default profile url', async () => {
+    const fetchSpy = jest.fn().mockReturnValue(Promise.resolve());
+    (global as any).fetch = fetchSpy;
+    const { result, waitForValueToChange } = renderHook(() => useUser(), {
+      wrapper: withUserProvider()
+    });
+
+    await waitForValueToChange(() => result.current.isLoading);
+    expect(fetchSpy).toHaveBeenCalledWith('/api/auth/me');
+  });
+
+  test('should accept a custom profile url', async () => {
     const fetchSpy = jest.fn().mockReturnValue(Promise.resolve());
     (global as any).fetch = fetchSpy;
     const { result, waitForValueToChange } = renderHook(() => useUser(), {
@@ -76,12 +92,33 @@ describe('context wrapper', () => {
     expect(fetchSpy).toHaveBeenCalledWith('/api/custom-url');
   });
 
+  test('should use a custom profile url from an environment variable', async () => {
+    process.env.NEXT_PUBLIC_AUTH0_PROFILE = '/api/custom-url';
+    const fetchSpy = jest.fn().mockReturnValue(Promise.resolve());
+    (global as any).fetch = fetchSpy;
+    const { result, waitForValueToChange } = renderHook(() => useUser(), {
+      wrapper: withUserProvider()
+    });
+
+    await waitForValueToChange(() => result.current.isLoading);
+    expect(fetchSpy).toHaveBeenCalledWith('/api/custom-url');
+    delete process.env.NEXT_PUBLIC_AUTH0_PROFILE;
+  });
+
+  test('should accept a custom login url', async () => {
+    const { result } = renderHook(() => useConfig(), {
+      wrapper: withUserProvider({ user, loginUrl: '/api/custom-url' })
+    });
+
+    expect(result.current.loginUrl).toEqual('/api/custom-url');
+  });
+
   test('should check the session when logged in', async () => {
     (global as any).fetch = fetchUserUnsuccessfulMock;
     const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() });
 
     await waitForValueToChange(() => result.current.isLoading);
-    expect(result.current.user).toBeUndefined;
+    expect(result.current.user).toBeUndefined();
 
     (global as any).fetch = fetchUserMock;
 
diff --git a/tests/frontend/with-page-auth-required.test.tsx b/tests/frontend/with-page-auth-required.test.tsx
index 82b42fde..a9bd9ce4 100644
--- a/tests/frontend/with-page-auth-required.test.tsx
+++ b/tests/frontend/with-page-auth-required.test.tsx
@@ -18,7 +18,7 @@ jest.mock('next/router', () => ({ useRouter: (): any => routerMock }));
 describe('with-page-auth-required csr', () => {
   afterEach(() => delete (global as any).fetch);
 
-  it('should block access to a CSR page when not authenticated', async () => {
+  it('should deny access to a CSR page when not authenticated', async () => {
     (global as any).fetch = fetchUserUnsuccessfulMock;
     const MyPage = (): JSX.Element => <>Private</>;
     const ProtectedPage = withPageAuthRequired(MyPage);
@@ -83,4 +83,15 @@ describe('with-page-auth-required csr', () => {
     render(<ProtectedPage />, { wrapper: withUserProvider() });
     await waitFor(() => expect(routerMock.push).toHaveBeenCalledWith(expect.stringContaining('?returnTo=/foo')));
   });
+
+  it('should use a custom login url', async () => {
+    process.env.NEXT_PUBLIC_AUTH0_LOGIN = '/api/foo';
+    (global as any).fetch = fetchUserUnsuccessfulMock;
+    const MyPage = (): JSX.Element => <>Private</>;
+    const ProtectedPage = withPageAuthRequired(MyPage);
+
+    render(<ProtectedPage />, { wrapper: withUserProvider() });
+    await waitFor(() => expect(routerMock.push).toHaveBeenCalledWith(expect.stringContaining('/api/foo')));
+    delete process.env.NEXT_PUBLIC_AUTH0_LOGIN;
+  });
 });