From f6d144dc3eada5e289878d8690d9117f7cce5b50 Mon Sep 17 00:00:00 2001 From: Hector Vergara Date: Thu, 11 Nov 2021 01:58:56 -0300 Subject: [PATCH] useAuth React hook (#601) * useAuthenticatedUser React hook --- .changeset/orange-eels-reflect.md | 5 + docs/src/pages/ui/hooks/auth/index.page.mdx | 7 + docs/src/pages/ui/hooks/auth/react.mdx | 37 +++++ packages/react/__tests__/exports.ts | 1 + .../react/src/hooks/__tests__/useAuth.test.ts | 132 ++++++++++++++++++ packages/react/src/hooks/index.tsx | 2 + packages/react/src/hooks/useAuth.ts | 75 ++++++++++ 7 files changed, 259 insertions(+) create mode 100644 .changeset/orange-eels-reflect.md create mode 100644 docs/src/pages/ui/hooks/auth/index.page.mdx create mode 100644 docs/src/pages/ui/hooks/auth/react.mdx create mode 100644 packages/react/src/hooks/__tests__/useAuth.test.ts create mode 100644 packages/react/src/hooks/useAuth.ts diff --git a/.changeset/orange-eels-reflect.md b/.changeset/orange-eels-reflect.md new file mode 100644 index 00000000000..58b07c830e1 --- /dev/null +++ b/.changeset/orange-eels-reflect.md @@ -0,0 +1,5 @@ +--- +"@aws-amplify/ui-react": patch +--- + +Add useAuth React hook diff --git a/docs/src/pages/ui/hooks/auth/index.page.mdx b/docs/src/pages/ui/hooks/auth/index.page.mdx new file mode 100644 index 00000000000..fd561c30c10 --- /dev/null +++ b/docs/src/pages/ui/hooks/auth/index.page.mdx @@ -0,0 +1,7 @@ +--- +title: Authentication +--- + +import { Fragment } from '@/components/Fragment'; + +{({ platform }) => import(`./${platform}.mdx`)} diff --git a/docs/src/pages/ui/hooks/auth/react.mdx b/docs/src/pages/ui/hooks/auth/react.mdx new file mode 100644 index 00000000000..d796dafdf2e --- /dev/null +++ b/docs/src/pages/ui/hooks/auth/react.mdx @@ -0,0 +1,37 @@ +## useAuth + +Access Amplify Auth metadata using a React hook. Returns an object containing: + +- `user`: `CognitoUser` user object, containing properties like `username` and `attributes`. +- `isLoading`: Indicates if metadata is being requested (`true` or `false`) +- `error`: If something fails while getting user metadata, it will contain details about the error (useful to debug issues) + +Notes: + +- `user` is set to `undefined` while data is being requested. Be careful to check `user` value before accessing `user.username` and `user.attributes`. +- Use `isLoading` and `error` values to provide a good user experience. + +The following React component example uses `useAuth` to greet an authenticated user: + +```jsx +import React from 'react'; +import { useAuth, Text } from '@aws-amplify/ui-react'; + +const UserGreeting = () => { + const { user, isLoading, error } = useAuth(); + + if (isLoading) { + return Loading user details...; + } + + if (error) { + return ( + Error retrieving user details: {error} + ); + } + + return Hello, {user.username}!; +}; +``` + +For more information about Amplify Auth, check out the [official Auth documentation](https://docs.amplify.aws/lib/auth/getting-started/q/platform/js/). diff --git a/packages/react/__tests__/exports.ts b/packages/react/__tests__/exports.ts index 98db4999331..4fc86ea960b 100644 --- a/packages/react/__tests__/exports.ts +++ b/packages/react/__tests__/exports.ts @@ -1890,6 +1890,7 @@ describe('@aws-amplify/ui-react', () => { "getOverridesFromVariants", "primitives", "useAmplify", + "useAuth", "useAuthenticator", "useDataStoreBinding", "useDataStoreCollection", diff --git a/packages/react/src/hooks/__tests__/useAuth.test.ts b/packages/react/src/hooks/__tests__/useAuth.test.ts new file mode 100644 index 00000000000..516f7d5223e --- /dev/null +++ b/packages/react/src/hooks/__tests__/useAuth.test.ts @@ -0,0 +1,132 @@ +import { Auth } from '@aws-amplify/auth'; +import { Hub } from '@aws-amplify/core'; +import { renderHook } from '@testing-library/react-hooks'; +import { act } from 'react-dom/test-utils'; +import { useAuth } from '../useAuth'; + +jest.mock('@aws-amplify/auth'); + +describe('useAuth', () => { + afterEach(() => jest.clearAllMocks()); + + it('should return default values when initialized', async () => { + (Auth.currentAuthenticatedUser as jest.Mock).mockResolvedValue(undefined); + + const { result, waitForNextUpdate } = renderHook(() => useAuth()); + + expect(result.current.user).toBe(undefined); + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeUndefined(); + + await waitForNextUpdate(); + }); + + it('should invoke Auth.currentAuthenticatedUser function', async () => { + const mockCurrentAuthenticatedUser = jest.fn(() => Promise.resolve()); + + (Auth.currentAuthenticatedUser as jest.Mock).mockImplementation( + mockCurrentAuthenticatedUser + ); + + const { waitForNextUpdate } = renderHook(() => useAuth()); + + await waitForNextUpdate(); + + expect(mockCurrentAuthenticatedUser).toHaveBeenCalled(); + }); + + it('should set an error when something unexpected happen', async () => { + (Auth.currentAuthenticatedUser as jest.Mock).mockRejectedValue( + new Error('Unknown error') + ); + + const { result, waitForNextUpdate } = renderHook(() => useAuth()); + + await waitForNextUpdate(); + + expect(result.current.error).not.toBeUndefined(); + }); + + it('should retrieve a Cognito user', async () => { + const mockCognitoUser = { + username: 'johndoe', + attributes: { + phone_number: '+1-234-567-890', + email: 'john@doe.com', + }, + }; + + (Auth.currentAuthenticatedUser as jest.Mock).mockResolvedValue( + mockCognitoUser + ); + + const { result, waitForNextUpdate } = renderHook(() => useAuth()); + + await waitForNextUpdate(); + + expect(result.current.error).toBeUndefined(); + expect(result.current.user).toBe(mockCognitoUser); + }); + + it('should receive a Cognito user on Auth.signIn Hub event', async () => { + const mockCognitoUser = { + username: 'johndoe', + attributes: { + phone_number: '+1-234-567-890', + email: 'john@doe.com', + }, + }; + + (Auth.currentAuthenticatedUser as jest.Mock).mockResolvedValue(undefined); + + const { result, waitForNextUpdate } = renderHook(() => useAuth()); + + await waitForNextUpdate(); + + expect(result.current.user).toBe(undefined); + + // Simulate Auth signIn Hub action + act(() => { + Hub.dispatch( + 'auth', + { event: 'signIn', data: mockCognitoUser }, + 'Auth', + Symbol.for('amplify_default') + ); + }); + + expect(result.current.user).toBe(mockCognitoUser); + }); + + it('should should unset user on Auth.signOut Hub event', async () => { + const mockCognitoUser = { + username: 'johndoe', + attributes: { + phone_number: '+1-234-567-890', + email: 'john@doe.com', + }, + }; + + (Auth.currentAuthenticatedUser as jest.Mock).mockResolvedValue( + mockCognitoUser + ); + + const { result, waitForNextUpdate } = renderHook(() => useAuth()); + + await waitForNextUpdate(); + + expect(result.current.user).toBe(mockCognitoUser); + + // Simulate Auth signOut Hub action + act(() => { + Hub.dispatch( + 'auth', + { event: 'signOut' }, + 'Auth', + Symbol.for('amplify_default') + ); + }); + + expect(result.current.user).toBeUndefined(); + }); +}); diff --git a/packages/react/src/hooks/index.tsx b/packages/react/src/hooks/index.tsx index 75794902e3c..e14351aec2e 100644 --- a/packages/react/src/hooks/index.tsx +++ b/packages/react/src/hooks/index.tsx @@ -1,5 +1,7 @@ export { useAmplify } from './useAmplify'; +export { useAuth } from './useAuth'; + export { useDataStoreBinding, useDataStoreCollection, diff --git a/packages/react/src/hooks/useAuth.ts b/packages/react/src/hooks/useAuth.ts new file mode 100644 index 00000000000..83c385f9a51 --- /dev/null +++ b/packages/react/src/hooks/useAuth.ts @@ -0,0 +1,75 @@ +import Auth, { CognitoUser } from '@aws-amplify/auth'; +import { Hub } from '@aws-amplify/core'; +import { useEffect, useState } from 'react'; + +// Exposes relevant CognitoUser properties +interface AuthUser extends CognitoUser { + username: string; + attributes: Record; +} + +export interface UseAuthResult { + user?: AuthUser; + isLoading: boolean; + error?: Error; + fetch?: () => void; +} + +/** + * React hook for Amplify Auth. + * Returns a reference to current authenticated Cognito `user`. + * + * Usage: + * ``` + * const { user, isLoading, error } = useAuth(); + * + * if (isLoading) { + * console.info('Fetching metadata for current user'); + * } + * + * if (error) { + * console.error(error.message); + * } + * + * if (user) { + * console.log(`Current username is ${user.username}`); + * } + * + * ``` + */ +export const useAuth = (): UseAuthResult => { + const [result, setResult] = useState({ + error: undefined, + isLoading: true, + user: undefined, + }); + + const handleAuth = ({ payload }) => { + switch (payload.event) { + case 'signIn': + return setResult({ user: payload.data, isLoading: false }); + case 'signOut': + return setResult({ isLoading: false }); + default: + break; + } + }; + + const fetch = () => { + setResult({ isLoading: true }); + + Auth.currentAuthenticatedUser() + .then((user) => setResult({ user, isLoading: false })) + .catch((error) => setResult({ error, isLoading: false })); + + // Handle Hub Auth events + Hub.listen('auth', handleAuth); + + // Stop listening events on unmount + return () => Hub.remove('auth', handleAuth); + }; + + useEffect(fetch, []); + + return { ...result, fetch }; +};